Error Handling
The widget surfaces backend errors through the onError callback. Most errors are network failures or transient backend hiccups that just need a retry — but a few have specific shapes worth handling explicitly.
With <Qafka /> in its default streaming mode (enableStreaming unset or true), what reaches onError for most error codes is a plain Error with message (the backend’s own message text) and status (the HTTP status code) — there’s no structured code property to switch on, so branch on status or match message if you need to tell cases apart. Two codes are the exception and get special handling before onError is ever called in this mode: project_unavailable (never reaches onError at all — see below) and a stale Test key in development (reaches onError with a hint message and code: 'DEV_KEY_INVALID', see below).
With enableStreaming={false}, error handling is less structured for errors hit while sending a message: every send failure — including project_unavailable — is re-wrapped into a plain Error(message) before it reaches onError, losing both status and any code. A project that’s already suspended when the widget mounts still fires onUnavailable in both streaming modes (that check happens during SDK initialization, before any send). It’s only a suspension that hits mid-session, on a non-streaming send, that arrives through onError (plus the default error bubble) instead of onUnavailable.
Using the headless SDK directly (calling sendMessageStream yourself instead of going through <Qafka />), your own onError callback receives the raw error, including a code: 'PROJECT_UNAVAILABLE' property for suspended projects — nothing intercepts it for you the way <Qafka />’s internal streaming path does.
Error reference
| Code | HTTP | Meaning | What to do |
|---|---|---|---|
RATE_LIMIT_EXCEEDED | 429 | A rate-limit ceiling was hit. See Throttling. | Back off before retrying. |
TEST_KEY_DAILY_CAP_EXCEEDED | 429 | A Test key’s daily call cap was reached. | Wait for the cap to reset, or switch to a Production key. |
SUSPICIOUS_TRAFFIC | 429 | The request pattern was flagged as suspicious. | Retry after a short delay. If this fires on legitimate traffic, contact support. |
BUDGET_EXCEEDED | 402 | The account’s plan usage quota (shared across all its projects) was reached. | Upgrade the plan, or contact your account manager. |
project_unavailable | 403 | The project is suspended and not currently serving requests. | Handle via onUnavailable — see below for the one non-streaming exception. |
PROJECT_ID_REQUIRED | 401 | Keyless attestation was attempted without a projectId. | Pass projectId to QafkaSDK.initialize() / <Qafka projectId="..." />. |
APP_NOT_REGISTERED | 401 | The app’s bundle ID / package name isn’t registered on the project, or its registration is disabled. | Add or re-enable the app under Project → Mobile Apps in the dashboard. |
Throttling (429)
Which rate-limit check applies to a request depends on how it authenticates:
- Requests carrying an API key (Production or Test) are checked against three independent counters, all scaled from your plan — the API key, the caller’s IP, and the session. Hitting any one of them returns
429. Test keys use their own fixed, stricter limits instead of the plan’s (see Test key daily cap). - Keyless native app traffic (device-attested, no API key) is checked against a single per-IP ceiling instead of the three-axis check above.
- Web SDK sessions (no API key, no hardware attestation) get their own stricter limits, scoped per visitor and per project — the lowest-trust traffic the backend accepts.
Response headers
Successful requests made with an API key carry the limit-aware headers (reflecting the API-key axis):
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 55Keyless and Web SDK traffic don’t carry these headers even on success.
429 Too Many Requests
When throttled, the response carries Retry-After (seconds) and a body shaped like:
{
"error": "RATE_LIMIT_EXCEEDED",
"message": "Rate limit exceeded (apiKey). Please try again later.",
"retryAfter": 30
}For the API-key path, message includes the axis that triggered the throttle (apiKey, session, or ip) — useful for debugging which one is hitting first.
<Qafka />’s onError doesn’t expose Retry-After or retryAfter programmatically — with default streaming, the Error reaching onError only carries message/status, not the response body’s retryAfter field. If you’re calling the API directly (outside the widget), read Retry-After off the response.
Handling 429
- Implement exponential backoff before retrying — the SDK doesn’t do this automatically inside
onError, and (from the widget) you don’t get a precise delay to wait on, so back off conservatively. - For chatty UIs (rapid typing, autosuggest), throttle client-side so user-driven bursts don’t burn your quota.
- Use Test keys during development so production counters stay clean.
- Watch usage on the Usage page — if you’re trending toward your plan’s ceiling, plan an upgrade before hitting it.
Test key daily cap (429)
Test keys skip device attestation, so they carry a fixed daily call cap on top of their per-minute limits, independent of the project’s plan. Response shape (abbreviated — the real message also states the exact cap):
{
"error": "TEST_KEY_DAILY_CAP_EXCEEDED",
"message": "Daily cap reached for this TEST key (…). Use a PRODUCTION key for higher throughput."
}This resets daily. Test keys use fixed limits that can’t be raised — see API Key Security › Limits for how this compares to Production keys.
Suspicious traffic (429)
Requests can occasionally be flagged as suspicious and rejected:
{
"error": "SUSPICIOUS_TRAFFIC",
"message": "Request pattern flagged as suspicious. Try again shortly."
}Retry after a short delay. If you’re seeing this on traffic you know is legitimate, contact support.
Budget (402)
Once the account’s plan usage quota is reached, requests fail with:
{
"error": "BUDGET_EXCEEDED",
"message": "Monthly quota reached. Please contact your account manager or upgrade."
}This quota is tied to the project owner’s subscription, not the individual project — an account with several projects shares one quota across all of them. The response also includes the current spend, the cap, the display unit, and when it resets, so you can build your own “approaching your limit” warning ahead of time. See Billing to check usage or upgrade.
A sibling error, NO_ACTIVE_SUBSCRIPTION (also 402), can occur if the account has no active subscription at all: {"error": "NO_ACTIVE_SUBSCRIPTION", "message": "Project has no active subscription. Please contact your administrator."}. This shouldn’t happen for a normally-onboarded account — treat it the same way as BUDGET_EXCEEDED (surface it and point the user to billing).
project_unavailable (403)
A suspended project (for example, after a plan downgrade that put it over the new plan’s project limit) returns a 403 with:
{
"error": "project_unavailable",
"status": "unavailable",
"message": "Assistant is currently unavailable"
}This is deliberately vague to end users — it says nothing about billing or plans.
With <Qafka /> in its default streaming mode, this does not reach onError: it’s routed to onUnavailable, and the SDK flips into an unavailable state that also gates the chat surface until it resolves. Handle onUnavailable (not onError) if you want to show your own “assistant offline” UI.
A project that’s already suspended when the widget mounts fires onUnavailable in both streaming modes — that check runs during SDK initialization (a theme prefetch), before enableStreaming ever comes into play. With enableStreaming={false}, it’s specifically a suspension that happens mid-session and is hit by a non-streaming send that behaves differently: it arrives through onError (plus the default error bubble) instead of onUnavailable. Headless callers using sendMessageStream directly get it in their own error callback as code: 'PROJECT_UNAVAILABLE' and decide for themselves how to handle it.
Keyless attestation errors (401)
These only apply if you’re using the keyless SDK build (no bundled API key — projectId + device attestation instead of a Production key):
PROJECT_ID_REQUIRED— the attestation request didn’t carry aprojectId. Pass it toQafkaSDK.initialize({ projectId })or theprojectIdprop on<Qafka />.APP_NOT_REGISTERED— the app’s bundle ID / Team ID (iOS) or package name / signing certificate (Android) isn’t registered on the project, or its registration is disabled (the two cases return the same error deliberately, so a disabled app can’t be distinguished from an unregistered one). Add or re-enable it under Project → Mobile Apps in the dashboard, then retry.
Stale Test key in development
If your Test key is rotated — automatically every 30 days, or because you deleted and replaced it from the dashboard — while your local .qafka/qafka-runtime.js still has the old value, requests fail with a plain 401. In development, the SDK recognizes this pattern (a qafka_test_-prefixed key getting a 401) and surfaces a more actionable message through onError (with code: 'DEV_KEY_INVALID') instead of a bare HTTP error:
Qafka dev key is invalid or was rotated. Run `qafka refresh`, then restart Metro with --clear.Follow the hint: qafka refresh fetches the current Test key into .qafka/qafka-runtime.js, and a Metro cache clear is needed because the stale value can otherwise stick around in the bundler cache.