Skip to content

Commit fa42583

Browse files
committed
fix: emit retry.giving_up when Retry-After exceeds max_delay
1 parent d89824d commit fa42583

5 files changed

Lines changed: 66 additions & 2 deletions

File tree

‎docs/observability.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ The logger and event names below are a stable public contract:
1313

1414
Each log record has an `event` field holding the event name, such as `event="circuit.opened"`, which you can filter on in your log aggregator. `circuit.rejected` is emitted at most once per second per circuit, so an open circuit under heavy traffic does not flood your logs. The first rejection of each outage is always emitted, `suppressed` counts the rejections left out since the previous one, and `circuit.closed` reports the outage's total in `rejected`. Events from `AsyncKeyedCircuitBreaker` and `KeyedCircuitBreaker` also carry `circuit_key`, the circuit they belong to. [Resilience](resilience.md) describes when each event fires.
1515

16+
`retry.giving_up` fires when a retryable failure is re-raised without another attempt, and its `reason` attribute says why. `max_attempts` means every attempt failed. `retry_after_exceeds_max_delay` means the server's `Retry-After` asked for a longer wait than `max_delay` allows; these events also carry `retry_after` and `max_delay` in seconds. Both kinds carry `attempts`, `method`, `url`, `last_status` and `last_exception_type`.
17+
1618
Events that carry a `url` attribute get it redacted first: httpware strips `user:pass@` userinfo and replaces every query-parameter value with `REDACTED`, keeping the keys. A URL logged as `https://api.test/items?page=REDACTED&sort=REDACTED` came from `https://api.test/items?page=2&sort=name`.
1719

1820
```python

‎docs/resilience.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ from httpware.middleware.resilience import AsyncRetry
2828
| `max_delay` | `5.0` (s) | Ceiling for backoff and the longest `Retry-After` honored. Must be finite and `>= 0`; a negative, NaN or infinite value raises `ValueError`. |
2929
| `retry_status_codes` | `frozenset({408, 429, 502, 503, 504})` | Status codes considered retryable. |
3030
| `retry_methods` | `frozenset({"GET", "HEAD", "OPTIONS", "PUT", "DELETE"})` | Idempotent methods only. To retry POST, pass a frozenset that includes `"POST"`. Matching ignores case, so `"post"` works too. |
31-
| `respect_retry_after` | `True` | When a retryable response has a `Retry-After` header, wait that long instead of the jittered backoff. If the value is over `max_delay`, `AsyncRetry` gives up and re-raises the `StatusError` with a PEP 678 note: `httpware: Retry-After (Ns) exceeded max_delay (Ms); giving up`. To avoid this, set `respect_retry_after=False` or raise `max_delay`. |
31+
| `respect_retry_after` | `True` | When a retryable response has a `Retry-After` header, wait that long instead of the jittered backoff. If the value is over `max_delay`, `AsyncRetry` gives up, emits `retry.giving_up` with `reason="retry_after_exceeds_max_delay"`, and re-raises the `StatusError` with a PEP 678 note: `httpware: Retry-After (Ns) exceeded max_delay (Ms); giving up`. To avoid this, set `respect_retry_after=False` or raise `max_delay`. |
3232
| `budget` | `RetryBudget()` | The token bucket. Pass one shared `RetryBudget` to several clients to give them a joint budget. |
3333

3434
To bound the total time across all attempts, put [`AsyncTimeout`](#asynctimeout) first in the chain. To bound a single request, set `httpx2.Timeout` on the client or pass `timeout=` per request.

‎src/httpware/middleware/resilience/retry.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,7 @@ def decide( # noqa: C901 — complexity budget: classification + streaming-body
179179
"url": str(request.url),
180180
"last_status": last_response.status_code if last_response is not None else None,
181181
"last_exception_type": type(exc).__qualname__,
182+
"reason": "max_attempts",
182183
},
183184
)
184185
raise exc
@@ -196,6 +197,22 @@ def decide( # noqa: C901 — complexity budget: classification + streaming-body
196197
max_delay=self.max_delay,
197198
),
198199
)
200+
_emit_event(
201+
_LOGGER,
202+
"retry.giving_up",
203+
level=logging.WARNING,
204+
message=f"retry gave up after {attempt + 1} attempts: Retry-After exceeded max_delay",
205+
attributes={
206+
"attempts": attempt + 1,
207+
"method": request.method,
208+
"url": str(request.url),
209+
"last_status": last_response.status_code if last_response is not None else None,
210+
"last_exception_type": type(exc).__qualname__,
211+
"reason": "retry_after_exceeds_max_delay",
212+
"retry_after": retry_after,
213+
"max_delay": self.max_delay,
214+
},
215+
)
199216
raise exc
200217

201218
if not self.budget.try_withdraw():

‎tests/test_retry.py‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -647,6 +647,28 @@ async def test_retry_giving_up_emits_observability_event(caplog: pytest.LogCaptu
647647
assert record.method == "GET" # ty: ignore[unresolved-attribute]
648648
assert record.last_status == HTTPStatus.SERVICE_UNAVAILABLE # ty: ignore[unresolved-attribute]
649649
assert record.last_exception_type == "ServiceUnavailableError" # ty: ignore[unresolved-attribute]
650+
assert record.reason == "max_attempts" # ty: ignore[unresolved-attribute]
651+
652+
653+
async def test_retry_after_exceeding_max_delay_emits_giving_up_event(caplog: pytest.LogCaptureFixture) -> None:
654+
sleeper = _SleepRecorder()
655+
handler = _ResponseSequenceWithHeaders([(HTTPStatus.SERVICE_UNAVAILABLE, {"Retry-After": "9999"})])
656+
client = _client(handler, retry=AsyncRetry(_sleep=sleeper, base_delay=0.01, max_delay=2.5))
657+
658+
with caplog.at_level(logging.WARNING, logger="httpware.retry"), pytest.raises(ServiceUnavailableError):
659+
await client.get("https://example.test/x")
660+
661+
records = [r for r in caplog.records if r.name == "httpware.retry" and r.event == "retry.giving_up"] # ty: ignore[unresolved-attribute]
662+
assert len(records) == 1
663+
record = records[0]
664+
assert record.levelno == logging.WARNING
665+
assert record.reason == "retry_after_exceeds_max_delay" # ty: ignore[unresolved-attribute]
666+
assert record.attempts == 1 # ty: ignore[unresolved-attribute]
667+
assert record.method == "GET" # ty: ignore[unresolved-attribute]
668+
assert record.last_status == HTTPStatus.SERVICE_UNAVAILABLE # ty: ignore[unresolved-attribute]
669+
assert record.last_exception_type == "ServiceUnavailableError" # ty: ignore[unresolved-attribute]
670+
assert record.retry_after == 9999.0 # noqa: PLR2004 — matches the header above # ty: ignore[unresolved-attribute]
671+
assert record.max_delay == 2.5 # noqa: PLR2004 — matches max_delay above # ty: ignore[unresolved-attribute]
650672

651673

652674
async def test_retry_budget_refused_emits_observability_event(caplog: pytest.LogCaptureFixture) -> None:

‎tests/test_retry_sync.py‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -488,7 +488,30 @@ def test_emits_giving_up_log_event(caplog: pytest.LogCaptureFixture) -> None:
488488
client = _client(handler, retry=Retry(_sleep=sleeper, base_delay=0.01, max_attempts=2))
489489
with caplog.at_level(logging.WARNING, logger="httpware.retry"), pytest.raises(ServiceUnavailableError):
490490
client.get("https://example.test/x")
491-
assert any("retry gave up" in r.getMessage() for r in caplog.records)
491+
giving_up = [r for r in caplog.records if r.name == "httpware.retry" and r.event == "retry.giving_up"] # ty: ignore[unresolved-attribute]
492+
assert len(giving_up) == 1
493+
assert giving_up[0].reason == "max_attempts" # ty: ignore[unresolved-attribute]
494+
495+
496+
def test_retry_after_exceeding_max_delay_emits_giving_up_event(caplog: pytest.LogCaptureFixture) -> None:
497+
sleeper = _SleepRecorder()
498+
handler = _ResponseSequenceWithHeaders([(HTTPStatus.SERVICE_UNAVAILABLE, {"Retry-After": "9999"})])
499+
client = _client(handler, retry=Retry(_sleep=sleeper, base_delay=0.01, max_delay=2.5))
500+
501+
with caplog.at_level(logging.WARNING, logger="httpware.retry"), pytest.raises(ServiceUnavailableError):
502+
client.get("https://example.test/x")
503+
504+
records = [r for r in caplog.records if r.name == "httpware.retry" and r.event == "retry.giving_up"] # ty: ignore[unresolved-attribute]
505+
assert len(records) == 1
506+
record = records[0]
507+
assert record.levelno == logging.WARNING
508+
assert record.reason == "retry_after_exceeds_max_delay" # ty: ignore[unresolved-attribute]
509+
assert record.attempts == 1 # ty: ignore[unresolved-attribute]
510+
assert record.method == "GET" # ty: ignore[unresolved-attribute]
511+
assert record.last_status == HTTPStatus.SERVICE_UNAVAILABLE # ty: ignore[unresolved-attribute]
512+
assert record.last_exception_type == "ServiceUnavailableError" # ty: ignore[unresolved-attribute]
513+
assert record.retry_after == 9999.0 # noqa: PLR2004 — matches the header above # ty: ignore[unresolved-attribute]
514+
assert record.max_delay == 2.5 # noqa: PLR2004 — matches max_delay above # ty: ignore[unresolved-attribute]
492515

493516

494517
def test_emits_budget_refused_log_event(caplog: pytest.LogCaptureFixture) -> None:

0 commit comments

Comments
 (0)