GuidesHandle usage limits

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 codelimitWhat it meansWhat to do
429More than 600 requests in the last minute on this key.Wait Retry-After seconds and retry the same request.
402 quota_exhaustedsessionThe 5-hour window of your plan is full and credits cannot take over.Pause until resets_at.
402 quota_exhaustedweeklyThe weekly window of your plan is full and credits cannot take over.Pause until resets_at and tell your team.
402 quota_exhaustedmonthlyThe plan’s monthly allowance is used and credits cannot take over.Stop; an administrator must upgrade or turn on credits.
402 quota_exhaustedspend_limitThe organization’s, or this member’s, monthly credit spend limit is reached.Stop; an administrator must raise the limit.
402 quota_exhaustedno_creditsCredits are on but the balance cannot cover this request.Stop; someone must buy credits or fix auto-reload.
402 quota_exhaustedunavailableUsage could not be checked just now.Retry with backoff.
500, 502, 503A 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:

  1. Retry soon: 429, 503 usage_check_unavailable, and other 5xx. Nothing is wrong with your account; wait briefly and send the same request again.
  2. Pause until a time: session and weekly. The API will accept requests again at resets_at without anyone doing anything. Show the time to your users and hold background work.
  3. Needs a person: monthly, spend_limit and no_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 limit values 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-id in 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_at to 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

LimitPrevention
Rate (429)Send work through a queue with a fixed number of workers; smooth bursts instead of firing everything at once.
session, weeklySchedule heavy batch jobs across the week rather than in one run.
monthlyTurn on usage credits so requests continue past the allowance. See Billing & credits.
no_creditsTurn on auto-reload with a saved payment method, and set max_tokens so the pre-request cost estimate stays small. See Pricing.
spend_limitSet 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.

  • 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.
Was this page helpful?