Handle usage limits
Sooner or later a request will be refused because a limit was reached. How your application should react depends on which limit it was: some clear in seconds, some in hours, and some only when a person changes a setting. This guide shows how to tell them apart and builds a small client wrapper, in Python and in Node.js, that reacts to each one correctly.
You will need: an API key in SYNTIC_API_KEY and either requests (Python) or Node.js 18 or later.
The responses to expect
Status and code | limit | What it means | What to do |
|---|---|---|---|
429 | More than 600 requests in the last minute on this key. | Wait Retry-After seconds and retry the same request. | |
402 quota_exhausted | session | The 5-hour window of your plan is full and credits cannot take over. | Pause until resets_at. |
402 quota_exhausted | weekly | The weekly window of your plan is full and credits cannot take over. | Pause until resets_at and tell your team. |
402 quota_exhausted | monthly | The plan’s monthly allowance is used and credits cannot take over. | Stop; an administrator must upgrade or turn on credits. |
402 quota_exhausted | spend_limit | The organization’s, or this member’s, monthly credit spend limit is reached. | Stop; an administrator must raise the limit. |
402 quota_exhausted | no_credits | Credits are on but the balance cannot cover this request. | Stop; someone must buy credits or fix auto-reload. |
402 quota_exhausted | unavailable | Usage could not be checked just now. | Retry with backoff. |
500, 502, 503 | A temporary problem on the server side. | Retry with backoff. |
A 402 body looks like this:
{
"error": {
"type": "insufficient_quota",
"code": "quota_exhausted",
"message": "You've hit your weekly usage limit. It resets at 2026-10-19T00:00:00.000Z. Enable usage credits or upgrade your plan to keep going.",
"param": null,
"request_id": "req_6f1c2a9e0b7d4c3a8e5f1b2d",
"limit": "weekly",
"resets_at": "2026-10-19T00:00:00.000Z",
"upgrade_url": "https://syntic.ai/pricing",
"add_credit_url": "https://syntic.ai/agents/billing#api-topup"
}
}resets_at is an ISO 8601 timestamp in UTC, or null when there is no fixed reset (for example unavailable). The message is written for people and may change; branch on limit, not on the text.
Three groups of reaction
It helps to reduce the table to three behaviors:
- Retry soon:
429,503 usage_check_unavailable, and other5xx. Nothing is wrong with your account; wait briefly and send the same request again. - Pause until a time:
sessionandweekly. The API will accept requests again atresets_atwithout anyone doing anything. Show the time to your users and hold background work. - Needs a person:
monthly,spend_limitandno_credits. Retrying will keep failing until an administrator changes something in the Console. Alert someone and fail clearly.
Every other 4xx is a problem with the request or the key (see Errors) and should not be retried.
The wrapper
import os
import random
import time
from datetime import datetime, timezone
import requests
API_URL = "https://app.syntic.ai/v1/chat/completions"
HEADERS = {"Authorization": f"Bearer {os.environ['SYNTIC_API_KEY']}"}
PAUSE = {"session", "weekly"}
NEEDS_ADMIN = {"monthly", "spend_limit", "no_credits"}
class UsagePaused(Exception):
"""The plan window is full; it reopens by itself at `resets_at`."""
def __init__(self, limit, resets_at):
super().__init__(f"{limit} window full until {resets_at}")
self.limit, self.resets_at = limit, resets_at
class UsageBlocked(Exception):
"""An administrator has to act before requests can succeed."""
def __init__(self, limit, message):
super().__init__(message)
self.limit = limit
def backoff(attempt):
# 1s, 2s, 4s, 8s ... plus up to one second of jitter
time.sleep(2 ** attempt + random.random())
def chat(messages, model="Amara S1.2", max_tokens=1024, attempts=5):
body = {"model": model, "messages": messages, "stream": False, "max_tokens": max_tokens}
for attempt in range(attempts):
try:
res = requests.post(API_URL, headers=HEADERS, json=body, timeout=180)
except requests.ConnectionError:
backoff(attempt)
continue
if res.ok:
source = res.headers.get("x-syntic-billing-source")
if source == "api_credit":
print("note: this request was paid from usage credits")
return res.json()
data = (res.json() if res.content else {}).get("error") or {}
if res.status_code == 429:
time.sleep(int(res.headers.get("Retry-After", "5")) + random.random())
continue
if res.status_code == 402 and data.get("code") == "quota_exhausted":
limit = data.get("limit")
if limit in PAUSE:
raise UsagePaused(limit, data.get("resets_at"))
if limit in NEEDS_ADMIN:
raise UsageBlocked(limit, data.get("message", ""))
backoff(attempt) # "unavailable" or anything new: treat as temporary
continue
if res.status_code >= 500:
backoff(attempt)
continue
# Any other 4xx: the request or the key is wrong. Retrying will not help.
detail = data.get("message") or ""
raise RuntimeError(f"{res.status_code} {data.get('error')}: {detail} "
f"(request {res.headers.get('x-syntic-request-id')})")
raise RuntimeError("Gave up after repeated temporary failures.")
if __name__ == "__main__":
try:
reply = chat([{"role": "user", "content": "Give me a two-line packing list for a rainy weekend."}])
print(reply["choices"][0]["message"]["content"])
except UsagePaused as paused:
when = datetime.fromisoformat(paused.resets_at.replace("Z", "+00:00"))
wait = when - datetime.now(timezone.utc)
print(f"Paused: the {paused.limit} limit reopens in {wait}.")
except UsageBlocked as blocked:
print(f"Blocked ({blocked.limit}). An administrator needs to act: {blocked}")Design notes
- Unknown
limitvalues are treated as temporary. New values may be added; retrying a few times and then giving up is safer than failing hard on the first unfamiliar one. - Retries are capped. Five attempts with doubling waits covers about half a minute. For background jobs you can allow more; for a person waiting on screen, fewer.
- The request ID is kept. Include
x-syntic-request-idin any message to Syntic support about one particular failure.
Limits in a streamed request
With streaming (the default), quota and rate checks happen before the stream starts. A refused request comes back as an ordinary JSON error with a non-200 status, never as an event inside the stream, so check response.ok (or the SDK’s exception) before reading events.
Once a stream has started it is not cut off by a limit; it runs to the end. If something else goes wrong mid-stream, you receive a final event whose error.code is stream_error. See Streaming.
Showing limits to your users
People cope well with a limit they understand. Some patterns:
- Session or weekly: “We’ve reached our usage limit for now. Service resumes at 3:40 PM.” Convert
resets_atto the user’s local time. - Monthly, spend limit, no credits: “This feature is temporarily unavailable. Our team has been notified.” Do not show billing details to end users of your product.
- Rate limit: usually invisible; the wrapper retries in the background. If retries run out, ask the user to try again in a minute.
Avoid hitting the limits
| Limit | Prevention |
|---|---|
Rate (429) | Send work through a queue with a fixed number of workers; smooth bursts instead of firing everything at once. |
session, weekly | Schedule heavy batch jobs across the week rather than in one run. |
monthly | Turn on usage credits so requests continue past the allowance. See Billing & credits. |
no_credits | Turn on auto-reload with a saved payment method, and set max_tokens so the pre-request cost estimate stays small. See Pricing. |
spend_limit | Set the monthly limit with headroom for your busiest month; review member and group limits. |
Watch the billing source
Every successful response carries x-syntic-billing-source:
subscription: covered by your plan allowance.api_credit: paid from usage credits.
When it flips from subscription to api_credit, one of your plan windows is full and you have started spending credits. That is a good moment to send an alert, before credits or spend limits run out too.
Read x-syntic-billing-source from server-side code. It is exposed to browsers as well, but your API key should not be in a browser in the first place.
Related pages
- Rate limits & retries: retry rules, backoff with jitter and duplicate-safe retries.
- Limits: every limit, with exact values.
- Errors: all error codes, including the ones that should not be retried.
- Usage & cost: where administrators see usage and change settings.