Skip to content

feat(base): 429 Retry-After + 5xx exponential backoff + RateLimitError #15

@mrrobot47

Description

@mrrobot47

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

  • A mocked 429 response with Retry-After: 5 causes the SDK to sleep 5s and retry, succeeding on the second attempt.
  • A mocked 429 response with no Retry-After header triggers the exponential-backoff schedule.
  • After exhausting retries on 429, the SDK raises RateLimitError (not InvalidRequestError), with retry_after populated from the last response.
  • A mocked 503 response triggers the exponential-backoff schedule; after exhaustion, raises ServerError.
  • AtomicClient(max_retries=0) retries nothing (existing behaviour preserved for callers who want it).
  • Connection-refused / timeout errors retry on the same schedule as 5xx.
  • 404 / 400 / 403 / etc. still raise immediately without retry.
  • Backup download via _get_raw retries on initial connection error but not mid-stream.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions