Python SDK retries, exceptions, constants
TL;DR Every client retries by default:
RetryPolicy()= 2 retries, exponential backoff 0.5s → 5.0s with 25% jitter, retryable statuses{408, 429, 500–599},Retry-Afterhonored, 30s total retry budget per call. HTTP timeouts are separate and default to 10.0s per operation. CatchTypeSafeErrorfor everything,TypeSafeAPIErrorfor HTTP failures (.status,.body,.headers,.endpoint,.request_id), andTypeSafeAPIConnectionError/TypeSafeAPITimeoutErrorfor transport failures. Disable retries withRetryPolicy(max_retries=0).
RetryPolicy
A frozen dataclass, importable as from typesafe_sdk import RetryPolicy. It may be passed on the client (retry=) or per call (system_one(..., retry=...) / models.list(retry=...)); a per-call policy fully replaces the client-level one for that call.
RetryPolicy(
max_retries: int = 2,
backoff_initial: float = 0.5,
backoff_max: float = 5.0,
backoff_jitter: float = 0.25,
http_statuses: set[int] = {408, 429, *range(500, 600)},
respect_retry_after: bool = True,
api_connection_error: bool = True,
api_timeout_error: bool = True,
exceptions: set[type[BaseException]] = set(),
predicate: Callable[[BaseException], bool] | None = None,
timeout: float | None = 30.0,
)
| Field | Type | Default | Description |
|---|---|---|---|
max_retries |
int |
2 |
Maximum retries after the initial attempt; 0 disables retries. So the default is up to 3 attempts total. |
backoff_initial |
float |
0.5 |
First backoff delay in seconds, doubled each attempt up to backoff_max; zero disables backoff. |
backoff_max |
float |
5.0 |
Maximum backoff delay in seconds; zero disables backoff. |
backoff_jitter |
float |
0.25 |
Fraction of each backoff delay randomly subtracted, between 0 and 1. |
http_statuses |
set[int] |
{408, 429, 500, 501, …, 599} (via default_factory) |
HTTP status codes that are retried. |
respect_retry_after |
bool |
True |
Honor Retry-After and retry-after-ms response headers. |
api_connection_error |
bool |
True |
Retry TypeSafeAPIConnectionError (cannot reach or read from the server). |
api_timeout_error |
bool |
True |
Retry TypeSafeAPITimeoutError (request exceeded its timeout). |
exceptions |
set[type[BaseException]] |
set() (via default_factory) |
Additional exception types that trigger a retry, on top of the built-in rules. |
predicate |
Callable[[BaseException], bool] | None |
None |
Called with the raised exception; returning True triggers a retry in addition to the other rules. |
timeout |
float | None |
30.0 |
Total retry budget in seconds per SDK call, including the initial attempt and delays; None disables the limit. Stops before a retry whose delay would reach or exceed the budget, re-raising the last error. |
Validation (__post_init__)
| Condition | Exception and message |
|---|---|
max_retries not an int, or negative |
TypeSafeError("max_retries must be a non-negative integer.") |
backoff_initial / backoff_max non-finite or negative |
TypeSafeError("<name> must be a non-negative, finite number of seconds.") |
backoff_jitter outside [0, 1] |
TypeSafeError("backoff_jitter must be between zero and one.") |
timeout not None and not a positive finite number (or httpx2.Timeout) |
TypeSafeError("timeout must be a positive, finite number of seconds.") (via resolve_timeout) |
This validation is the 0.6.0 bug fix "handle invalid values in RetryPolicy".
Which failures are retried
RetryPolicy._retryable(error) returns True when any of these hold:
isinstance(error, TypeSafeAPITimeoutError)andapi_timeout_errorisTrue; elseisinstance(error, TypeSafeAPIConnectionError)andapi_connection_errorisTrue; elseisinstance(error, TypeSafeAPIError)anderror.status in http_statuses; else not retryable by the built-in rules — thenisinstance(error, tuple(self.exceptions)), orpredicate is not None and predicate(error).
The three built-in branches are mutually exclusive (TypeSafeAPITimeoutError is checked first because it subclasses TypeSafeAPIConnectionError). Note that TypeSafeAPIResponseValidationError subclasses TypeSafeAPIError and carries the successful status code, so it is retried only if that status happens to be in http_statuses — with the defaults, a 200 is not.
Default retryable statuses: 408 (Request Timeout), 429 (Too Many Requests), and every 5xx from 500 through 599. Non-retryable by default: 400, 401, 403, 404, 422. See HTTP status codes, rate limits, retry semantics.
Backoff and Retry-After
For attempt n (1-based, n = 1 is the wait before the first retry), _backoff computes:
exponential = min(backoff_initial * 2**(n-1), backoff_max)delay = exponential * (1 - random() * backoff_jitter), rounded to 3 decimals, capped atexponential- if
backoff_initial == 0orbackoff_max == 0, the delay is0.0
With defaults that is ~0.5s then ~1.0s (each minus up to 25%).
When respect_retry_after is True and the error is a TypeSafeAPIError carrying a retry header, that header overrides the computed backoff entirely. parse_retry_after checks headers in this order:
| Header | Unit | Multiplier to ms |
|---|---|---|
retry-after-ms |
milliseconds | ×1 |
retry-after |
seconds, or an HTTP-date | ×1000 |
Rules from _core/errors.py:parse_retry_after:
- A numeric value must be finite; negative values on
retry-after-msare skipped, a negativeretry-afterreturnsNone. - An empty/whitespace value is treated as
0. - A non-numeric
retry-afteris parsed as an HTTP-date; the wait ismax(0, date - now)in milliseconds. - The function returns milliseconds; the retry layer divides by 1000 to get seconds.
The total budget is enforced with tenacity's stop_after_attempt(max_retries + 1) | stop_before_delay(timeout) and reraise=True, so the original exception (not a RetryError) surfaces.
Each retried request carries X-TypeSafe-Retry-Count: <attempt> and logs "<METHOD> <url> retry <n>" at INFO.
Examples
from typesafe_sdk import RetryPolicy, TypeSafeClient
client = TypeSafeClient(
retry=RetryPolicy(max_retries=3, timeout=10.0, http_statuses={429, 500, 502, 503, 504})
)
from typesafe_sdk import Noul, RetryPolicy, TypeSafeClient
with TypeSafeClient() as client:
# client-level
client_policy = RetryPolicy(max_retries=3, backoff_max=0.2, timeout=1.0)
# per-call override
result = client.system_one(
"I was charged twice.",
{"billing": Noul(instructions="Is this about billing?")},
retry=RetryPolicy(max_retries=0), # no retries for this call
)
print(result.nouls["billing"].noul)
Retry on an extra exception type, or with a custom predicate:
import httpx2
from typesafe_sdk import RetryPolicy, TypeSafeAPIError, TypeSafeClient
policy = RetryPolicy(
exceptions={httpx2.RemoteProtocolError},
predicate=lambda error: isinstance(error, TypeSafeAPIError) and error.status == 409,
)
client = TypeSafeClient(retry=policy)
Timeouts
Two independent budgets:
| Setting | Default | Scope | Where |
|---|---|---|---|
timeout on the client / per call |
10.0 s (constants.DEFAULT_TIMEOUT) |
each HTTP operation (connect/read/write/pool, per httpx2.Timeout semantics) |
TypeSafeClient(timeout=...), system_one(..., timeout=...), models.list(timeout=...) |
RetryPolicy.timeout |
30.0 s |
the whole SDK call: initial attempt + all retries + delays | RetryPolicy(timeout=...) |
Precedence for the HTTP timeout: per-call timeout → client timeout → http_client.timeout (when you supplied an http_client and passed no timeout) → DEFAULT_TIMEOUT (10.0).
Pass an httpx2.Timeout for fine-grained control:
import httpx2
from typesafe_sdk import TypeSafeClient
client = TypeSafeClient(timeout=httpx2.Timeout(connect=2.0, read=20.0, write=10.0, pool=5.0))
An expired HTTP timeout raises TypeSafeAPITimeoutError, which is retryable by default; when the retry budget is exhausted it propagates.
Exception hierarchy
Exception
└── TypeSafeError
├── TypeSafeAPIError
│ ├── TypeSafeBadRequestError (400)
│ ├── TypeSafeAuthenticationError (401)
│ ├── TypeSafePermissionDeniedError (403)
│ ├── TypeSafeNotFoundError (404)
│ ├── TypeSafeUnprocessableEntityError (422)
│ ├── TypeSafeRateLimitError (429)
│ ├── TypeSafeInternalServerError (5xx)
│ └── TypeSafeAPIResponseValidationError (2xx with an unusable body)
└── TypeSafeAPIConnectionError (also subclasses ConnectionError)
└── TypeSafeAPITimeoutError (also subclasses TimeoutError)
| Class | Bases | Raised when | Extra attributes |
|---|---|---|---|
TypeSafeError |
Exception |
Base for all SDK failures; also raised directly for config/validation problems (missing API key, invalid timeout, empty questions, invalid RetryPolicy, unencodable body, missing request id). |
— |
TypeSafeAPIError |
TypeSafeError |
Any unsuccessful HTTP response after retries. | status: int, body: Any, headers: httpx2.Headers, endpoint: str | None, request_id: str | None (property) |
TypeSafeBadRequestError |
TypeSafeAPIError |
400 — the request was invalid. | inherited |
TypeSafeAuthenticationError |
TypeSafeAPIError |
401 — authentication failed. | inherited |
TypeSafePermissionDeniedError |
TypeSafeAPIError |
403 — access was denied. | inherited |
TypeSafeNotFoundError |
TypeSafeAPIError |
404 — resource not found. | inherited |
TypeSafeUnprocessableEntityError |
TypeSafeAPIError |
422 — failed server validation. | inherited |
TypeSafeRateLimitError |
TypeSafeAPIError |
429 — rate limit exceeded. | retry_after_ms: float | None plus inherited |
TypeSafeInternalServerError |
TypeSafeAPIError |
any status ≥ 500. | inherited |
TypeSafeAPIResponseValidationError |
TypeSafeAPIError |
A successful HTTP response whose body was missing or structurally invalid required data. | field_path: str (e.g. answers.tone.confidence); args == (status, body, headers, field_path, endpoint) |
TypeSafeAPIConnectionError |
TypeSafeError, ConnectionError |
The request failed without an HTTP response. | — |
TypeSafeAPITimeoutError |
TypeSafeAPIConnectionError, TimeoutError |
The request exceeded its configured timeout. | timeout: float | httpx2.Timeout |
Status→class mapping (STATUS_ERROR_TYPES in _core/errors.py): 400, 401, 403, 404, 422, 429 map to the classes above; any other status ≥ 500 becomes TypeSafeInternalServerError; anything else becomes a plain TypeSafeAPIError.
Because TypeSafeAPIConnectionError also subclasses ConnectionError and TypeSafeAPITimeoutError also subclasses TimeoutError, generic except ConnectionError / except TimeoutError handlers in surrounding code will catch them.
Error attributes in detail
status— HTTP response status code.body— the server's JSON error body, plain response text, orNonefor an empty body.headers—httpx2.Headersof the response.endpoint— the request method and URL, without credentials, query parameters, or fragment, when available (e.g.POST https://api.typesafe.ai/v1/systemone). This is the 0.6.0 "error messages include http details and metadata" improvement.request_id— thex-typesafe-request-idresponse header, orNoneif absent.retry_after_ms(429 only) — the server's requested wait in milliseconds, orNone.field_path(validation only) — dotted path to the first offending field.
__str__ renders "{endpoint}: {status} {message} (request_id={id})", omitting the parts that are unavailable. The message is derived from the body by extract_message, which tries, in order: body as a string → body["error"] (string) → body["error"]["message"] → body["message"] → body["detail"] (string) → body["detail"]["message"] → a FastAPI-style body["detail"] list joined as "loc.path: msg; …". If nothing matches, the serialized body is truncated to MAX_ERROR_BODY_LENGTH (200) characters with an ellipsis; an empty body yields "status code (no body)". __repr__ deliberately omits the body and headers. Exceptions are picklable as of 0.6.0.
Handling examples
from typesafe_sdk import TypeSafeAPIError, TypeSafeClient
try:
client.system_one(state, questions)
except TypeSafeAPIError as error:
print(error.status, error.request_id)
Full triage:
import logging
import time
from typesafe_sdk import (
Noul,
TypeSafeAPIConnectionError,
TypeSafeAPIError,
TypeSafeAPIResponseValidationError,
TypeSafeAPITimeoutError,
TypeSafeAuthenticationError,
TypeSafeClient,
TypeSafeError,
TypeSafeRateLimitError,
)
with TypeSafeClient() as client:
try:
result = client.system_one(
"I was charged twice.",
{"billing": Noul(instructions="Is this about billing?")},
)
except TypeSafeAuthenticationError:
raise SystemExit("Check TYPESAFE_API_KEY")
except TypeSafeRateLimitError as error:
wait = (error.retry_after_ms or 1000) / 1000
logging.warning("rate limited, waiting %.1fs (request %s)", wait, error.request_id)
time.sleep(wait)
except TypeSafeAPIResponseValidationError as error:
logging.error("bad response body at %s: %s", error.field_path, error.endpoint)
except TypeSafeAPITimeoutError as error:
logging.error("timed out after %s", error.timeout)
except TypeSafeAPIConnectionError as error:
logging.error("connection failure: %s", error)
except TypeSafeAPIError as error:
logging.error("%s %s", error.status, error)
except TypeSafeError as error: # config / validation problems, no HTTP made
logging.error("client-side error: %s", error)
else:
print(result.nouls["billing"].noul)
Catch order matters: TypeSafeAPITimeoutError before TypeSafeAPIConnectionError, the specific TypeSafeAPIError subclasses before TypeSafeAPIError, and TypeSafeError last.
Constants
Public — typesafe_sdk.constants
| Name | Value | Meaning |
|---|---|---|
API_KEY_ENV |
'TYPESAFE_API_KEY' |
Environment variable for the API key. |
BASE_URL_ENV |
'TYPESAFE_BASE_URL' |
Environment variable for the API base URL. |
DEFAULT_MODEL_ENV |
'TYPESAFE_DEFAULT_MODEL' |
Environment variable for the default model. |
LOG_LEVEL_ENV |
'TYPESAFE_LOG_LEVEL' |
Environment variable for the logging level. |
DEFAULT_BASE_URL |
'https://api.typesafe.ai' |
Default API base URL. |
DEFAULT_MODEL |
'jev-latest' |
Default model name. |
DEFAULT_TIMEOUT |
10.0 |
Default timeout in seconds for each HTTP operation. |
from typesafe_sdk import constants
print(constants.DEFAULT_BASE_URL, constants.DEFAULT_MODEL, constants.DEFAULT_TIMEOUT)
Internal — typesafe_sdk._core.constants
Not part of the public API (absent from the docs and from __all__), but they define the wire behavior you will see on the network:
| Name | Value |
|---|---|
SYSTEM_ONE_PATH |
/v1/systemone |
MODELS_PATH |
/v1/models |
SDK_NAME |
typesafe-sdk |
LOGGER_NAME |
typesafe_sdk |
JSON_CONTENT_TYPE |
application/json |
MAX_ERROR_BODY_LENGTH |
200 |
AUTHORIZATION_HEADER |
Authorization |
ACCEPT_HEADER |
Accept |
CONTENT_TYPE_HEADER |
Content-Type |
USER_AGENT_HEADER |
User-Agent |
SDK_HEADER |
X-TypeSafe-SDK |
RUNTIME_HEADER |
X-TypeSafe-Runtime |
RETRY_COUNT_HEADER |
X-TypeSafe-Retry-Count |
REQUEST_ID_HEADER |
x-typesafe-request-id |
RETRY_AFTER_HEADER |
retry-after |
RETRY_AFTER_MS_HEADER |
retry-after-ms |
SECRET_HEADERS |
frozenset({authorization, proxy-authorization, x-api-key, api-key, cookie, set-cookie}) |
Treat these as version-specific implementation detail; depend on the public constants module instead.
Version notes
0.6.0 changed retry/error behavior in two ways recorded in the changelog: RetryPolicy now rejects invalid values (see the validation table), and errors carry HTTP details and metadata (endpoint, richer __str__). Exceptions and responses became picklable. See Python SDK changelog.
Related
- Python SDK: install, clients, system_one() — clients,
system_one(), headers, logging - Python SDK responses, answers, usage, models — what you get when no exception is raised
- Python SDK changelog — release history
- HTTP status codes, rate limits, retry semantics — HTTP status codes and retry semantics at the API level
- TYPESAFE_* environment variables across SDKs —
TYPESAFE_*across SDKs - JavaScript SDK error classes, RetryPolicy, RequestOptions — the JS/TS equivalents
- HTTP API: POST /v1/systemone and GET /v1/models — endpoints and error bodies
Sources
- raw/docs/sdk__python__api__retries.md (https://docs.typesafe.ai/sdk/python/api/retries.md)
- raw/docs/sdk__python__api__exceptions.md (https://docs.typesafe.ai/sdk/python/api/exceptions.md)
- raw/docs/sdk__python__api__constants.md (https://docs.typesafe.ai/sdk/python/api/constants.md)
- raw/docs/sdk__python__usage.md (https://docs.typesafe.ai/sdk/python/usage.md)
- raw/github/typesafe-sdk-python/src/typesafe_sdk/_core/retry.py, _core/errors.py, _core/constants.py, _core/transport.py, _core/config.py, constants.py (https://github.com/typesafe-ai/typesafe-sdk-python @ 420ef4ffb612d5a539a1e0f0fe883ff6770340af)