Summary
The SDK's _request in atomic_sdk/api/base.py has no retry behaviour. 429 responses (rate-limited) and 5xx responses (server errors) fail immediately with no backoff, and 429 specifically is bucketed into the generic InvalidRequestError bag. Add an opt-in retry policy so transient server conditions don't surface as user-visible errors, and introduce a distinct RateLimitError so callers can handle rate-limiting deliberately.
Scope
1. New exception RateLimitError(AtomicAPIError)
- Location:
atomic_sdk/exceptions.py.
- Subclass of
AtomicAPIError; adds retry_after: int | None attribute parsed from the Retry-After header.
- Raised specifically on 429 responses (after exhausting retries, if retries are enabled).
2. Retry policy in _request
Configurable via AtomicClient constructor:
max_retries: int = 3 — default 3; 0 disables.
backoff_base: float = 0.5 — seconds; doubling with jitter.
Behaviour:
- 429: respect
Retry-After header if present (either seconds or HTTP-date). If header absent, fall through to exponential backoff. Retry up to max_retries times. After exhaustion, raise RateLimitError with the last response's Retry-After attached.
- 5xx: exponential backoff with full jitter (
random.uniform(0, backoff_base * 2**attempt)). Retry up to max_retries. After exhaustion, raise ServerError as today.
- 4xx (non-429): no retry. Raise the appropriate specific exception immediately (existing behaviour).
- Connection errors (DNS, connection reset, timeout): retry with the same 5xx backoff schedule.
3. Classify 429
Currently 400–499 (non-404) maps to InvalidRequestError. Split: 429 → RateLimitError. All other 4xx (non-404) continue to map to InvalidRequestError.
4. Method scope
Apply the retry policy to:
_request (covers _get / _post).
_get_raw (backup downloads). Retrying a streaming download partway through is unsafe; retry only on the initial request-establishment error, not mid-stream.
Do NOT apply to _get_stream (once #12 lands) — streaming retries mid-transfer are footguns; caller decides.
Constructor changes
AtomicClient(..., max_retries: int = 3, backoff_base: float = 0.5).
- Defaults chosen to match what most HTTP clients provide; the SDK's current "no retry" default is unusual and surprises users.
Logging
- On each retry, emit a single log line via
logging.getLogger("atomic_sdk.retry") at WARNING level: "429 Retry-After=30s, retrying (attempt 1/3)" / "500 Internal Server Error, backing off 0.62s (attempt 2/3)". No logger configured by default; caller opts in.
Acceptance criteria
Out of scope
- Async / concurrent retry. The SDK is sync; this is the sync retry path only.
- Circuit breakers across calls. Overkill for this SDK's usage patterns.
- Retrying non-idempotent POSTs is a question for callers; default is to retry all methods. Callers can set
max_retries=0 if they worry about duplicate POSTs; the _request path doesn't distinguish idempotency.
References
Summary
The SDK's
_requestinatomic_sdk/api/base.pyhas no retry behaviour. 429 responses (rate-limited) and 5xx responses (server errors) fail immediately with no backoff, and 429 specifically is bucketed into the genericInvalidRequestErrorbag. Add an opt-in retry policy so transient server conditions don't surface as user-visible errors, and introduce a distinctRateLimitErrorso callers can handle rate-limiting deliberately.Scope
1. New exception
RateLimitError(AtomicAPIError)atomic_sdk/exceptions.py.AtomicAPIError; addsretry_after: int | Noneattribute parsed from theRetry-Afterheader.2. Retry policy in
_requestConfigurable via
AtomicClientconstructor:max_retries: int = 3— default 3;0disables.backoff_base: float = 0.5— seconds; doubling with jitter.Behaviour:
Retry-Afterheader if present (either seconds or HTTP-date). If header absent, fall through to exponential backoff. Retry up tomax_retriestimes. After exhaustion, raiseRateLimitErrorwith the last response'sRetry-Afterattached.random.uniform(0, backoff_base * 2**attempt)). Retry up tomax_retries. After exhaustion, raiseServerErroras today.3. Classify 429
Currently 400–499 (non-404) maps to
InvalidRequestError. Split: 429 →RateLimitError. All other 4xx (non-404) continue to map toInvalidRequestError.4. Method scope
Apply the retry policy to:
_request(covers_get/_post)._get_raw(backup downloads). Retrying a streaming download partway through is unsafe; retry only on the initial request-establishment error, not mid-stream.Do NOT apply to
_get_stream(once #12 lands) — streaming retries mid-transfer are footguns; caller decides.Constructor changes
AtomicClient(..., max_retries: int = 3, backoff_base: float = 0.5).Logging
logging.getLogger("atomic_sdk.retry")atWARNINGlevel:"429 Retry-After=30s, retrying (attempt 1/3)"/"500 Internal Server Error, backing off 0.62s (attempt 2/3)". No logger configured by default; caller opts in.Acceptance criteria
Retry-After: 5causes the SDK to sleep 5s and retry, succeeding on the second attempt.Retry-Afterheader triggers the exponential-backoff schedule.RateLimitError(notInvalidRequestError), withretry_afterpopulated from the last response.ServerError.AtomicClient(max_retries=0)retries nothing (existing behaviour preserved for callers who want it)._get_rawretries on initial connection error but not mid-stream.Out of scope
max_retries=0if they worry about duplicate POSTs; the_requestpath doesn't distinguish idempotency.References
atomic_sdk/api/base.py::_request(the try/except forHTTPError).atomic_sdk/exceptions.py.