Every error response shares the same shape:
{
"error": {
"code": "RATE_LIMITED",
"message": "Rate limit exceeded (60 req/min). Retry in 12s.",
"limit": 60,
"current": 60
}
}
code— stable machine-readable identifier. Subject to additions but never silent renames.message— human-readable, may evolve. Do not parse.limit/current— present only for quota errors.
Status codes
| Status | Code(s) | Meaning | Retry? |
|---|---|---|---|
400 | VALIDATION_ERROR | Body or query parameter failed schema validation. | No — fix the request. |
401 | UNAUTHORIZED | Missing, malformed, expired, or revoked key. | No — mint a fresh key. |
403 | UNAUTHORIZED | Resource exists but not in this key’s project. | No — wrong project key. |
404 | NOT_FOUND | Flow, run, or file doesn’t exist. | No. |
409 | RUN_NOT_RUNNING | Operation requires a running instance (e.g. POST /messages to a sleeping run). | After resuming, or stop trying. |
409 | (none) | Self-hosted runner offline at trigger time. | Yes, after the runner reconnects. |
429 | RATE_LIMITED | Per-key request rate exceeded. Respect Retry-After. | Yes, with backoff. |
429 | CONCURRENT_LIMIT | Project at its concurrent-runs cap. | Yes, once a run finishes. |
429 | MONTHLY_QUOTA | Project hit its monthly run quota. | Not until next month, or upgrade. |
500 | (none) | Bug on our side. | Yes, with backoff. |
503 | UPSTREAM_UNAVAILABLE | Container is up but the WS upstream isn’t accepting input (brief race). | Yes, immediately. |
Suggested retry policy
attempt 1 → fail → wait 1 s
attempt 2 → fail → wait 2 s
attempt 3 → fail → wait 4 s
attempt 4 → fail → give up
Only apply this to retryable statuses (429, 500, 503, network errors). Use the Retry-After header on 429 instead of your own backoff when it’s present.
For 429 RATE_LIMITED specifically, the X-RateLimit-Reset header tells you the epoch seconds when the bucket will refill — you can wait until then exactly.
Typed errors in the SDK
import {
FlowKoiAuthError,
FlowKoiNotFoundError,
FlowKoiRateLimitError,
FlowKoiValidationError,
FlowKoiUpstreamError,
} from "@flowkoi/sdk";
try {
await flowkoi.flows.run(flowId, { prompt });
} catch (err) {
if (err instanceof FlowKoiRateLimitError) {
await sleep(err.retryAfterSeconds * 1000);
return retry();
}
if (err instanceof FlowKoiNotFoundError) return notify("Flow has been deleted");
if (err instanceof FlowKoiAuthError) return notify("API key rotated; refresh");
throw err;
}
Every typed error carries the raw body from the response, so you can still introspect err.body?.error?.code if you need a code we haven’t given a dedicated class to yet.
Error logs in the dashboard
Every API call is recorded in the per-key daily usage aggregates (count + errors + total duration). The API tab in Project Settings renders a 30-day chart so you can spot regressions without scraping logs.