A wrapper you can put in front of every call. The value is not in the retrying — it is in knowing what not to retry.
The wrapper#
import randomimport timefrom openai import OpenAI, APIStatusError, APIConnectionError, APITimeoutErrorclient = OpenAI(base_url="https://ai.upfyn.com/v1",api_key=KEY,timeout=60.0,max_retries=0, # we do this ourselves; the SDK would retry a 402)# Retrying anything else is either pointless or harmful.RETRYABLE_STATUS = {429, 500, 502, 503, 504}class OutOfCredits(Exception):"""402. No amount of retrying fixes this."""class BadRequest(Exception):"""400/403/404. Your request is wrong; it will be wrong next time too."""def complete(*, attempts: int = 5, **kwargs):for attempt in range(attempts):try:return client.chat.completions.create(**kwargs)except APIStatusError as err:status = err.status_codeif status == 402:raise OutOfCredits(_message(err)) from errif status in (400, 401, 403, 404, 413):raise BadRequest(f"{status}: {_message(err)}") from errif status not in RETRYABLE_STATUS or attempt == attempts - 1:raisetime.sleep(_backoff(err, attempt))except (APIConnectionError, APITimeoutError):# The request may never have reached us. Safe to retry.if attempt == attempts - 1:raisetime.sleep(_backoff(None, attempt))raise RuntimeError("unreachable")def _backoff(err, attempt: int) -> float:"""Honour Retry-After when the server sent one; otherwise exponential with jitter."""if err is not None:header = err.response.headers.get("Retry-After")if header:try:return min(float(header), 60.0)except ValueError:pass# Jitter matters: without it, every client that failed together retries together.return min(2 ** attempt, 30) + random.random()def _message(err) -> str:try:return err.response.json()["error"]["message"]except Exception:return str(err)
What to retry, and what never to#
| Status | Retry? | Why |
|---|---|---|
| 429 | Yes | Rate limited. Honour Retry-After; retrying harder makes it worse. |
| 500 / 502 / 503 / 504 | Yes | Ours. Back off and try again. |
| Connection / timeout | Yes | It may never have reached us. |
| 400 | No | Your request is malformed. It will be malformed next time. |
| 401 | No | Bad credentials. A retry sends the same bad credentials. |
| 402 | No | Out of credits. Retrying just burns time and logs noise. |
| 403 | No | Not permitted. Retrying will not grant permission. |
| 413 | No | The file is too big and will stay too big. |
Turn the SDK's retries off
Both OpenAI SDKs retry by default, and their policy does not know that a402 is hopeless. Set max_retries=0 and own the decision, or you will silently make three failing calls where one would do.Backoff with jitter#
The jitter is not decoration. When a burst of requests hits a rate limit together, they all fail together — and without jitter they all retry at the same instant and fail together again. A random fraction of a second spreads them out.
- Prefer
Retry-Afterwhen it is present. The server knows better than your formula. - Cap the wait. An unbounded exponential eventually sleeps for hours.
- Cap the attempts. Five is plenty; if five fail, something is actually wrong.
Not paying twice#
If a response is lost in transit, your retry could be billed as a second request. Idempotency-Key prevents that — reuse the same key for every attempt at one logical request.
import uuid# Same key on every attempt of the SAME logical request. A repeat is not billed twice.key = str(uuid.uuid4())complete(model="upfyn-yuva",messages=messages,extra_headers={"Idempotency-Key": key},)
Failing before you start#
For expensive work, check the balance first. Discovering you are empty halfway through a batch is worse than not starting it.
def complete_with_budget(budget_credits: float, **kwargs):"""Refuse to start work you cannot afford to finish."""me = client.get("/me", cast_to=dict)if me["total_credits"] < budget_credits:raise OutOfCredits(f"need {budget_credits}, have {me['total_credits']}")return complete(**kwargs)
The full list#
Every status, type and code the API can return is on Errors, including what a mid-stream failure looks like — which is the case this wrapper does not cover, because by then the response is already a 200.
