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.
Since 0.7.1 the usage guide pairs it with a warning worth internalising: "Invalid API keys raise TypeSafeError during client creation, before any request or retry." No retry policy can rescue a bad key, because no request is ever attempted.
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 or invalid 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.
Credential redaction in transport errors (0.7.1)
0.7.1's bug fix — "validate the API key early and exclude the value from logged exceptions" — has two halves. The first is the early API-key validation described under Python SDK: install, clients, system_one(). The second lives in _core/logging.py:redact_exception, called from _core/transport.py whenever httpx2 raises a RequestError:
| Step | Behavior |
|---|---|
| Collect credentials | Every value of a header in SECRET_HEADERS, plus the credential half of Authorization / Proxy-Authorization (the part after the scheme). |
| Build variants | Each credential in four encodings — raw, repr-escaped, byte-repr-escaped, and JSON-escaped — because "errors may contain raw tokens, repr-style header bytes, or JSON-escaped values". |
| Rewrite | Longest-match-first regex substitution with ***, applied to str(error), every exception in the __cause__ / __context__ chain (cycles handled), and every __notes__ entry. Exceptions whose constructor needs more than a message are replaced by a plain Exception("ClassName: message"). |
| Re-raise | TypeSafeAPITimeoutError(request.timeout) or TypeSafeAPIConnectionError(f"Connection error: {safe_error}"), raise ... from safe_error, then sdk_error.__context__ = None — "Raising inside this handler implicitly attaches the unredacted original", so it is detached deliberately. |
Consequence for your own logging: logging.exception(...) on a caught TypeSafeAPIConnectionError cannot leak the API key through the chained httpx2 error. This is separate from, and in addition to, the header redaction in SensitiveHeadersFilter; request and response bodies are still not redacted.
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
Described for typesafe-sdk 0.7.1 (repo captured at commit 0ffd094c72ed9445223060b24ffd7a56aa781fb4, 2026-09-21).
- 0.6.0 changed retry/error behavior in two ways recorded in the changelog:
RetryPolicynow rejects invalid values (see the validation table), and errors carry HTTP details and metadata (endpoint, richer__str__). Exceptions and responses became picklable. - 0.7.0 left
_core/retry.pyand_core/errors.pyuntouched —RetryPolicy, every exception class, the status mapping, backoff, andparse_retry_afterare byte-identical to the 0.6.0 snapshot. The only visible change is upstream of them:_core/transport.pynow catchesPydanticSerializationError,TypeErrorandValueError(wasmsgspec.EncodeError,TypeError) when the request body cannot be encoded, still raisingTypeSafeError("The request body could not be encoded as JSON"). ATypeSafeAPIResponseValidationErrornow originates from apydantic.ValidationErrorrather than amsgspec.DecodeError, but the class,.field_pathand.argsare unchanged. - 0.7.1 added early API-key validation and the exception redaction described above.
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, _core/logging.py, constants.py (https://github.com/typesafe-ai/typesafe-sdk-python @ 0ffd094c72ed9445223060b24ffd7a56aa781fb4, captured 2026-09-21)