GuidesRate limits & retries

Rate limits & retries

Most failed requests are worth a second try, but not all of them. This page explains how the request cap works today, which failures you can safely send again, how long to wait between attempts, and gives a ready-made retry helper in Python and TypeScript.

For the full list of limits (request size, plan windows, spend limits) see Limits. For per-model token limits that are still being designed, see Rate-limit tiers and Rate-limit headers.

How the request cap works

PropertyBehaviour today
Cap600 requests per minute
Counted perAPI key. Syntic Code sign-in tokens are counted per user.
WindowSliding: each request looks back over the previous 60 seconds, so there is no moment in the minute when the count drops to zero.
When the shared counter is downThe cap stays on. Each server falls back to its own count, so enforcement can be a little uneven for a short while, but traffic is never let through unchecked.
Extra keysEach key has its own counter, but spreading load over keys is not a supported way to raise the cap.

Going over the cap returns 429 Too Many Requests:

HTTP/1.1 429 Too Many Requests
Retry-After: 7
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 0
X-RateLimit-Reset-Seconds: 7
Content-Type: application/json
 
{"error":{"type":"rate_limit_error","code":"rate_limited","message":"Too many requests. Retry shortly.","param":null,"request_id":"req_6f1c2a9e0b7d4c3a8e5f1b2d","retry_after":7,"retry_after_seconds":7}}
HeaderMeaning
Retry-AfterWhole seconds before the window has room for you again. Read this first.
X-RateLimit-LimitThe cap that was applied (600).
X-RateLimit-RemainingAlways 0.
X-RateLimit-Reset-SecondsSame number as Retry-After. (There is no X-RateLimit-Reset.)

Points to keep in mind:

  • The X-RateLimit-* headers come only with a 429 from Syntic’s own request cap. Successful responses carry no rate-limit headers, so pace yourself with a fixed worker pool rather than by watching a counter.
  • A 429 or 503 caused by the model service being busy carries a Retry-After set by Syntic (kept within a sensible range) and no X-RateLimit-* headers.
  • Browser JavaScript cannot read the X-RateLimit-* headers. Use Retry-After, or retry_after_seconds from the 429 body where the header is not readable. See Rate-limit headers.

What to retry

ResponseRetry?How
429 rate limitYesWait at least Retry-After seconds, then send the same request.
502 upstream_unavailableYesExponential backoff. The model service could not be reached; nothing ran.
503 (any code)YesExponential backoff, or Retry-After when present.
500Yes, a few timesExponential backoff.
upstream_error with a 5xx statusYesExponential backoff.
stream_error event inside a 200 streamYes, with careThe reply was cut off part-way. Send the whole request again; the part already delivered is billed, so cap these retries tightly.
Network error or timeout before any responseYesExponential backoff. If the request may have run, see Duplicate-safe retries.
503 usage_check_unavailableYesExponential backoff. Usage could not be checked for a moment.
402 quota_exhausted, any other limitNoA plan window, spend limit or credit balance ran out. Retrying spends attempts without effect. See Handle usage limits.
400, 413, 422 and other validation errorsNoThe request itself is wrong. Fix it first.
401, 403NoThe key, its scopes or the account need attention.
404NoWrong path, or (on develop) an unknown model name.
upstream_error with a 4xx statusNoThe model service rejected the request, for example because it is longer than the context window.

A simple rule of thumb: retry 429, 5xx and broken connections; never retry a 4xx other than 429 without changing something.

How long to wait

Check Retry-After first. When the response has it, wait that many seconds plus a small random amount. Only when it is missing, fall back to exponential backoff with jitter:

  1. Start from a base delay, for example 0.5 s, and double it on each attempt: 0.5, 1, 2, 4, 8 s.
  2. Cap the delay (for example at 30 s) so one request never sleeps for minutes.
  3. Pick a random value between zero and that delay (“full jitter”). Without randomness, workers that failed together retry together and collide again.
  4. Stop after a fixed number of attempts (four to six is typical) and surface the error with its x-syntic-request-id.

Retries add load, so they belong at one layer only. If you use an official SDK, either turn its built-in retries off (max_retries=0 / maxRetries: 0) and use a helper like the one below, or rely on the SDK and do not wrap it in another retry loop.

Retry helper

Both helpers send a non-streamed chat completion to https://app.syntic.ai/v1/chat/completions, retry only the cases marked “Yes” above, honour Retry-After, and raise anything else straight away.

import os
import random
import time
 
import requests
 
API_URL = "https://app.syntic.ai/v1/chat/completions"
HEADERS = {"Authorization": f"Bearer {os.environ['SYNTIC_API_KEY']}"}
 
BASE_DELAY = 0.5   # seconds
MAX_DELAY = 30.0   # seconds
MAX_ATTEMPTS = 5
 
 
class ApiError(Exception):
    def __init__(self, status, body, request_id):
        super().__init__(f"{status} {body.get('error')} (request {request_id})")
        self.status, self.body, self.request_id = status, body, request_id
 
 
def should_retry(status, body):
    if status == 429 or status >= 500:
        return True
    return status == 503 and (body.get("error") or {}).get("code") == "usage_check_unavailable"
 
 
def wait_time(attempt, retry_after):
    # Retry-After wins whenever the server sent one.
    if retry_after and retry_after.strip().isdigit():
        return int(retry_after) + random.uniform(0, 1)
    return random.uniform(0, min(MAX_DELAY, BASE_DELAY * 2 ** attempt))
 
 
def chat(messages, model="Amara S1.2", max_tokens=1024):
    payload = {"model": model, "messages": messages, "stream": False, "max_tokens": max_tokens}
 
    for attempt in range(MAX_ATTEMPTS):
        last = attempt == MAX_ATTEMPTS - 1
        try:
            res = requests.post(API_URL, headers=HEADERS, json=payload, timeout=120)
        except (requests.ConnectionError, requests.Timeout):
            if last:
                raise
            time.sleep(wait_time(attempt, None))
            continue
 
        if res.ok:
            return res.json()
 
        try:
            body = res.json()
        except ValueError:
            body = {}
        if not isinstance(body, dict):
            body = {}
 
        if last or not should_retry(res.status_code, body):
            raise ApiError(res.status_code, body, res.headers.get("x-syntic-request-id"))
 
        time.sleep(wait_time(attempt, res.headers.get("Retry-After")))
 
 
if __name__ == "__main__":
    reply = chat([{"role": "user", "content": "Name three uses for a paperclip."}])
    print(reply["choices"][0]["message"]["content"])

For streamed requests, wrap the same loop around the whole stream: if you read a data: {"error":"stream_error",...} event, throw away the partial text and start the request again, counting it as one attempt.

Duplicate-safe retries

A chat completion has no side effects of its own, so running it twice only costs tokens. When the request may have been processed but the answer never reached you (a dropped connection, a timeout), resending it simply produces a second answer that is billed again. Keep timeouts generous and attempt counts low to limit that cost.

⚠️

Not live yet — mobile app only for now. The Idempotency-Key header described below is accepted only on the conversation endpoints used by the Syntic mobile app. It is not part of the public API, and sending it to /v1/chat/completions has no effect. It is documented here so you know what is coming.

With an Idempotency-Key, a retry of a request that already finished returns the stored answer instead of running again:

SituationResponse
First request with a keyRuns normally.
Same key, same body, first request finishedThe original result is replayed: same content, usage and ids, as JSON or as a stream to match the original. The response carries x-syntic-idempotent-replay: true. Nothing runs and nothing is billed.
Same key, same body, first request still running409 idempotency_request_in_progress. Wait about two seconds and try again.
Same key, different body409 idempotency_key_reused. Use a new key for a new request.
Same key, first attempt failed without being billedRuns again as a fresh attempt.
Same key, the stored turn was deleted since409 conversation_turn_deleted. Nothing is run or restored.
Same key, more than 24 hours after the first request finished409 idempotency_key_expired. A finished request is never run a second time.

Details of the key:

  • Format: 1 to 255 visible ASCII characters. A random UUID per request is a good choice.
  • Scope: a key belongs to one user and one conversation. The same string used in another conversation is a different key.
  • Body match: the body is compared after its keys are sorted, so field order and whitespace do not matter.
  • Lifetime: replays work for 24 hours after the first request finishes. A finished key stays reserved for as long as its conversation exists; a key whose attempt failed is released after 30 days.
  • Client rule: generate one key per logical request, reuse it for every retry of that request, and drop queued sends that are more than 24 hours old.

All 409 responses above use the error type conflict_error. Only idempotency_request_in_progress is worth retrying unchanged.

Was this page helpful?