Skip to content

Commit d89824d

Browse files
committed
fix: match retry_methods case-insensitively
1 parent 644a47f commit d89824d

4 files changed

Lines changed: 27 additions & 3 deletions

File tree

‎docs/resilience.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ from httpware.middleware.resilience import AsyncRetry
2727
| `base_delay` | `0.1` (s) | Floor for the full-jitter exponential backoff. Must be `>= 0`; a negative or NaN value raises `ValueError`. |
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. |
30-
| `retry_methods` | `frozenset({"GET", "HEAD", "OPTIONS", "PUT", "DELETE"})` | Idempotent methods only. To retry POST, pass a frozenset that includes `"POST"`. |
30+
| `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. |
3131
| `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`. |
3232
| `budget` | `RetryBudget()` | The token bucket. Pass one shared `RetryBudget` to several clients to give them a joint budget. |
3333

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ def __init__( # noqa: PLR0913 — retry policy has many orthogonal knobs; a dat
122122
self.base_delay = base_delay
123123
self.max_delay = max_delay
124124
self.retry_status_codes = retry_status_codes
125-
self.retry_methods = retry_methods
125+
self.retry_methods = frozenset(method.upper() for method in retry_methods)
126126
self.respect_retry_after = respect_retry_after
127127
self.budget = budget if budget is not None else RetryBudget()
128128

@@ -139,7 +139,7 @@ def decide( # noqa: C901 — complexity budget: classification + streaming-body
139139
exception. `exc` is the currently-handled exception (one of
140140
_RETRYABLE_EXCEPTIONS); see the class docstring for chaining semantics.
141141
"""
142-
method_eligible = request.method.upper() in self.retry_methods
142+
method_eligible = request.method in self.retry_methods
143143
if isinstance(exc, StatusError):
144144
retryable_status = exc.response.status_code in self.retry_status_codes
145145
if not method_eligible or not retryable_status:

‎tests/test_retry.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,18 @@ async def test_retries_post_when_method_explicitly_included() -> None:
128128
assert handler.calls == 2 # noqa: PLR2004 — "2" is intentional literal in test assertion
129129

130130

131+
async def test_retry_methods_match_case_insensitively() -> None:
132+
sleeper = _SleepRecorder()
133+
handler = _ResponseSequence([HTTPStatus.SERVICE_UNAVAILABLE, HTTPStatus.OK])
134+
client = _client(
135+
handler,
136+
retry=AsyncRetry(_sleep=sleeper, retry_methods=frozenset({"post"}), base_delay=0.01, max_delay=0.02),
137+
)
138+
response = await client.post("https://example.test/x", json={"x": 1})
139+
assert response.status_code == HTTPStatus.OK
140+
assert handler.calls == 2 # noqa: PLR2004 — initial attempt + 1 retry
141+
142+
131143
async def test_max_attempts_one_means_no_retries() -> None:
132144
sleeper = _SleepRecorder()
133145
handler = _ResponseSequence([HTTPStatus.SERVICE_UNAVAILABLE])

‎tests/test_retry_sync.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,6 +457,18 @@ def test_retries_post_when_method_explicitly_included() -> None:
457457
assert len(sleeper.calls) == 1
458458

459459

460+
def test_retry_methods_match_case_insensitively() -> None:
461+
sleeper = _SleepRecorder()
462+
handler = _ResponseSequence([HTTPStatus.SERVICE_UNAVAILABLE, HTTPStatus.OK])
463+
client = _client(
464+
handler,
465+
retry=Retry(_sleep=sleeper, retry_methods=frozenset({"post"}), base_delay=0.01, max_delay=0.02),
466+
)
467+
response = client.post("https://example.test/x", json={"k": "v"})
468+
assert response.status_code == HTTPStatus.OK
469+
assert handler.calls == 2 # noqa: PLR2004 — initial attempt + 1 retry
470+
471+
460472
def test_default_budget_is_fresh_per_instance() -> None:
461473
r1 = Retry()
462474
r2 = Retry()

0 commit comments

Comments
 (0)