diff --git a/README.md b/README.md index 8ecce4f..d1280d0 100644 --- a/README.md +++ b/README.md @@ -223,7 +223,7 @@ pytest tests/ -v ### Core Reliability - [ ] Request timeout support - [ ] AbortController integration -- [ ] Retry-After header support +- [x] Retry-After header support - [ ] Custom retry strategies ### Observability diff --git a/packages/smooth-api-py/smooth_api/__init__.py b/packages/smooth-api-py/smooth_api/__init__.py index 967c0c3..9810fa1 100644 --- a/packages/smooth-api-py/smooth_api/__init__.py +++ b/packages/smooth-api-py/smooth_api/__init__.py @@ -28,6 +28,20 @@ def _get_status_code(err: Exception) -> int | None: return None +def _get_retry_after_delay(err: Exception) -> float | None: + if hasattr(err, "response") and err.response is not None: + if hasattr(err.response, "headers"): + retry_after = err.response.headers.get("Retry-After") + if retry_after is not None: + try: + delay = float(retry_after) + if delay > 0: + return delay + except ValueError: + pass + return None + + class MockResponse: def __init__(self, status_code: int, content: dict, reason: str = ""): self.status_code = status_code @@ -125,7 +139,12 @@ async def _execute(): breaker.record_failure(domain) last_err = err if attempt < config.backoff.max_retries: - await asyncio.sleep(calculate_backoff(attempt, config.backoff)) + delay = calculate_backoff(attempt, config.backoff) + if status == 429: + retry_after_delay = _get_retry_after_delay(err) + if retry_after_delay is not None: + delay = retry_after_delay + await asyncio.sleep(delay) continue # If retries are exhausted and it's an HTTP error, return the response instead of raising @@ -190,7 +209,12 @@ def wrapper(*args, **kwargs): # type: ignore[misc] breaker.record_failure(domain) last_err = err if attempt < config.backoff.max_retries: - sleep_backoff(calculate_backoff(attempt, config.backoff)) + delay = calculate_backoff(attempt, config.backoff) + if status == 429: + retry_after_delay = _get_retry_after_delay(err) + if retry_after_delay is not None: + delay = retry_after_delay + sleep_backoff(delay) continue if status is not None and hasattr(err, 'response'): diff --git a/packages/smooth-api-py/tests/test_resilience.py b/packages/smooth-api-py/tests/test_resilience.py index f987c52..e151348 100644 --- a/packages/smooth-api-py/tests/test_resilience.py +++ b/packages/smooth-api-py/tests/test_resilience.py @@ -70,6 +70,89 @@ def get_data(): assert call_count[0] >= 1 +def test_retry_respects_retry_after_header_sync(): + """Retry-After header on 429 should override backoff calculation.""" + config = SmoothConfig( + backoff=BackoffConfig(base_delay=10.0, max_delay=10.0, max_retries=1), + circuit_breaker=CircuitBreakerConfig(failure_threshold=10, cooldown_ms=60_000), + retry_on=[429], + ) + + call_count = [0] + + class MockResponse: + status_code = 429 + headers = {"Retry-After": "0.1"} + reason = "Too Many Requests" + + class MockResponseSuccess: + status_code = 200 + headers = {} + reason = "OK" + + def json(self): + return {"success": True} + + @smooth_api(config) + def get_data(): + call_count[0] += 1 + if call_count[0] == 1: + err = requests.exceptions.HTTPError("429") + err.response = MockResponse() + raise err + + return MockResponseSuccess() + + start_time = time.time() + get_data() + duration = time.time() - start_time + + assert call_count[0] == 2 + assert 0.1 <= duration < 5.0 + + +@pytest.mark.asyncio +async def test_retry_respects_retry_after_header_async(): + """Retry-After header on 429 should override backoff calculation for async functions.""" + config = SmoothConfig( + backoff=BackoffConfig(base_delay=10.0, max_delay=10.0, max_retries=1), + circuit_breaker=CircuitBreakerConfig(failure_threshold=10, cooldown_ms=60_000), + retry_on=[429], + ) + + call_count = [0] + + class MockResponse: + status_code = 429 + headers = {"Retry-After": "0.1"} + reason = "Too Many Requests" + + class MockResponseSuccess: + status_code = 200 + headers = {} + reason = "OK" + + def json(self): + return {"success": True} + + @smooth_api(config) + async def get_data_async(): + call_count[0] += 1 + if call_count[0] == 1: + err = requests.exceptions.HTTPError("429") + err.response = MockResponse() + raise err + + return MockResponseSuccess() + + start_time = time.time() + await get_data_async() + duration = time.time() - start_time + + assert call_count[0] == 2 + assert 0.1 <= duration < 5.0 + + # ─── Circuit breaker ────────────────────────────────────────────────────────── def test_circuit_trips_and_returns_fallback(): diff --git a/packages/smooth-api-ts/src/index.ts b/packages/smooth-api-ts/src/index.ts index 5621218..e77d47c 100644 --- a/packages/smooth-api-ts/src/index.ts +++ b/packages/smooth-api-ts/src/index.ts @@ -72,7 +72,17 @@ export function createSmoothFetch(globalConfig: SmoothFetchConfig) { if (retryOn.includes(response.status)) { breaker.recordFailure(domain); if (attempt < backoffConfig.maxRetries) { - await sleep(calculateBackoff(attempt, backoffConfig)); + let delayMs = calculateBackoff(attempt, backoffConfig); + if (response.status === 429) { + const retryAfter = response.headers.get('Retry-After'); + if (retryAfter) { + const parsed = parseInt(retryAfter, 10); + if (!Number.isNaN(parsed) && parsed > 0) { + delayMs = parsed * 1000; + } + } + } + await sleep(delayMs); continue; } return response; diff --git a/packages/smooth-api-ts/tests/resilience.test.ts b/packages/smooth-api-ts/tests/resilience.test.ts index 0d6c0eb..04ee63c 100644 --- a/packages/smooth-api-ts/tests/resilience.test.ts +++ b/packages/smooth-api-ts/tests/resilience.test.ts @@ -25,6 +25,40 @@ describe('retry logic', () => { const res = await smoothFetch(`${BASE}/unstable-data`) as Response; assert.ok(res.ok || res.status < 500, 'should eventually get a non-500 response'); }); + + it('respects Retry-After header for 429 status', async () => { + const smoothFetch = createSmoothFetch({ + backoff: { baseDelay: 10, maxDelay: 50, maxRetries: 1 }, + circuitBreaker: { failureThreshold: 10, cooldownMs: 60_000 }, + retryOn: [429], + }); + + const originalFetch = globalThis.fetch; + let callCount = 0; + globalThis.fetch = async () => { + callCount++; + if (callCount === 1) { + return new Response(null, { + status: 429, + headers: { 'Retry-After': '1' } + }); + } + return new Response(null, { status: 200 }); + }; + + try { + const start = Date.now(); + const res = await smoothFetch(`${BASE}/some-url`) as Response; + const duration = Date.now() - start; + + assert.equal(res.status, 200); + assert.equal(callCount, 2); + // Wait time should be at least 1000ms (1 second) due to Retry-After: 1 + assert.ok(duration >= 1000, `Expected delay >= 1000ms, got ${duration}ms`); + } finally { + globalThis.fetch = originalFetch; + } + }); }); describe('circuit breaker', () => {