Skip to content

Commit 98681e0

Browse files
lesnik512claude
andcommitted
feat(resilience): Retry refuses requests with streaming bodies
Closes the deferred-work item 'Retry + streaming bodies'. When the request was constructed with an async-iterable content/data/files, _request_with_body marked request.extensions['httpware.streaming_body'] = True. Retry now reads the marker and re-raises the original failure with a PEP-678 note ('not retrying — request body is a stream that cannot replay across attempts') instead of retrying with a consumed iterator. Check happens BEFORE budget.try_withdraw() so a refused retry doesn't consume a budget token. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 1555586 commit 98681e0

2 files changed

Lines changed: 154 additions & 2 deletions

File tree

src/httpware/middleware/resilience/retry.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ def __init__( # noqa: PLR0913 — retry policy has many orthogonal knobs; a dat
9090
self.budget = budget if budget is not None else RetryBudget()
9191
self._sleep = _sleep
9292

93-
async def __call__(self, request: httpx2.Request, next: Next) -> httpx2.Response: # noqa: A002, C901, PLR0912 — complexity budget: 3 error clauses + idempotency gate + budget gate + Retry-After branch + backoff
93+
async def __call__(self, request: httpx2.Request, next: Next) -> httpx2.Response: # noqa: A002, C901, PLR0912, PLR0915 — complexity budget: 3 error clauses + idempotency gate + streaming-body refusal + budget gate + Retry-After branch + backoff
9494
"""Process a request through the retry loop. See module docstring."""
9595
method_eligible = request.method.upper() in self.retry_methods
9696
last_exc: BaseException | None = None
@@ -106,24 +106,46 @@ async def __call__(self, request: httpx2.Request, next: Next) -> httpx2.Response
106106
else:
107107
return await next(request)
108108
except StatusError as exc:
109-
if not method_eligible or exc.response.status_code not in self.retry_status_codes:
109+
retryable_status = exc.response.status_code in self.retry_status_codes
110+
if not method_eligible or not retryable_status:
111+
if retryable_status and request.extensions.get("httpware.streaming_body"):
112+
exc.add_note(
113+
"httpware: not retrying — request body is a stream that cannot replay across attempts"
114+
)
110115
raise
111116
last_exc = exc
112117
last_response = exc.response
113118
except (NetworkError, TimeoutError) as exc:
114119
if not method_eligible:
120+
if request.extensions.get("httpware.streaming_body"):
121+
exc.add_note(
122+
"httpware: not retrying — request body is a stream that cannot replay across attempts"
123+
)
115124
raise
116125
last_exc = exc
117126
last_response = None
118127
except builtins.TimeoutError as exc:
119128
wrapped = TimeoutError("attempt timed out")
120129
wrapped.__cause__ = exc # set now; the retry path (last_exc = wrapped) has no `from` clause
121130
if not method_eligible:
131+
if request.extensions.get("httpware.streaming_body"):
132+
wrapped.add_note(
133+
"httpware: not retrying — request body is a stream that cannot replay across attempts"
134+
)
122135
raise wrapped from exc
123136
last_exc = wrapped
124137
last_response = None
125138

126139
# ---- retryable failure path
140+
if request.extensions.get("httpware.streaming_body"):
141+
if last_exc is None: # pragma: no cover — invariant from except branch
142+
msg = "Retry: streaming-body refusal reached with no last_exc"
143+
raise AssertionError(msg)
144+
last_exc.add_note(
145+
"httpware: not retrying — request body is a stream that cannot replay across attempts"
146+
)
147+
raise last_exc
148+
127149
if is_last:
128150
if last_exc is None: # pragma: no cover — structural invariant from except branch
129151
msg = "Retry: last_exc unset on final attempt — unreachable"

tests/test_retry.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -520,3 +520,133 @@ async def streamed_files() -> typing.AsyncIterator[bytes]:
520520
yield b"x" # pragma: no cover
521521

522522
assert _is_streaming_body(streamed_files()) is True
523+
524+
525+
async def test_retry_refuses_streamed_body_request() -> None:
526+
"""Retry must not replay a request with a streaming body — re-raise with a PEP-678 note."""
527+
sleeper = _SleepRecorder()
528+
call_count = {"n": 0}
529+
530+
def handler(request: httpx2.Request) -> httpx2.Response:
531+
call_count["n"] += 1
532+
return httpx2.Response(HTTPStatus.SERVICE_UNAVAILABLE, request=request)
533+
534+
async def streamed_body() -> typing.AsyncIterator[bytes]:
535+
yield b"x"
536+
537+
transport = httpx2.MockTransport(handler)
538+
client = AsyncClient(
539+
httpx2_client=httpx2.AsyncClient(transport=transport),
540+
middleware=[Retry(_sleep=sleeper, base_delay=0.001, max_delay=0.002)],
541+
)
542+
543+
with pytest.raises(ServiceUnavailableError) as info:
544+
await client.post("https://example.test/upload", content=streamed_body())
545+
546+
assert call_count["n"] == 1
547+
assert sleeper.calls == [] # no retry attempted
548+
notes = getattr(info.value, "__notes__", [])
549+
assert any("not retrying" in note and "stream" in note for note in notes)
550+
551+
552+
async def test_retry_refuses_streamed_body_does_not_consume_budget() -> None:
553+
"""When Retry refuses for streaming-body reasons, no budget token is withdrawn."""
554+
sleeper = _SleepRecorder()
555+
budget = RetryBudget(ttl=10.0, min_retries_per_sec=10.0, percent_can_retry=0.2)
556+
557+
def handler(request: httpx2.Request) -> httpx2.Response:
558+
return httpx2.Response(HTTPStatus.SERVICE_UNAVAILABLE, request=request)
559+
560+
async def streamed_body() -> typing.AsyncIterator[bytes]:
561+
yield b"x"
562+
563+
transport = httpx2.MockTransport(handler)
564+
client = AsyncClient(
565+
httpx2_client=httpx2.AsyncClient(transport=transport),
566+
middleware=[Retry(_sleep=sleeper, budget=budget, base_delay=0.001, max_delay=0.002)],
567+
)
568+
569+
with pytest.raises(ServiceUnavailableError):
570+
await client.post("https://example.test/upload", content=streamed_body())
571+
572+
# Budget should be untouched: deposits OK (every attempt deposits), but no withdrawals.
573+
# Check via _withdrawn deque emptiness.
574+
assert len(budget._withdrawn) == 0 # noqa: SLF001 — implementation-detail access for invariant
575+
576+
577+
async def test_retry_refuses_streamed_body_network_error_non_idempotent() -> None:
578+
"""Streaming POST that hits a NetworkError gets the PEP-678 note."""
579+
sleeper = _SleepRecorder()
580+
581+
def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001
582+
msg = "transient"
583+
raise httpx2.ConnectError(msg)
584+
585+
async def streamed_body() -> typing.AsyncIterator[bytes]:
586+
yield b"x"
587+
588+
transport = httpx2.MockTransport(handler)
589+
client = AsyncClient(
590+
httpx2_client=httpx2.AsyncClient(transport=transport),
591+
middleware=[Retry(_sleep=sleeper, base_delay=0.001, max_delay=0.002)],
592+
)
593+
594+
with pytest.raises(NetworkError) as info:
595+
await client.post("https://example.test/upload", content=streamed_body())
596+
597+
assert sleeper.calls == [] # no retry attempted
598+
notes = getattr(info.value, "__notes__", [])
599+
assert any("not retrying" in note and "stream" in note for note in notes)
600+
601+
602+
async def test_retry_refuses_streamed_body_attempt_timeout_non_idempotent() -> None:
603+
"""Streaming POST that times out per attempt_timeout gets the PEP-678 note."""
604+
sleeper = _SleepRecorder()
605+
606+
async def slow_handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001
607+
await asyncio.sleep(1.0)
608+
msg = "should not reach" # pragma: no cover
609+
raise AssertionError(msg) # pragma: no cover
610+
611+
async def streamed_body() -> typing.AsyncIterator[bytes]:
612+
yield b"x"
613+
614+
transport = httpx2.MockTransport(slow_handler)
615+
client = AsyncClient(
616+
httpx2_client=httpx2.AsyncClient(transport=transport),
617+
middleware=[Retry(_sleep=sleeper, attempt_timeout=0.05, base_delay=0.001, max_delay=0.002)],
618+
)
619+
620+
with pytest.raises(HttpwareTimeoutError) as info:
621+
await client.post("https://example.test/upload", content=streamed_body())
622+
623+
assert sleeper.calls == [] # no retry attempted
624+
notes = getattr(info.value, "__notes__", [])
625+
assert any("not retrying" in note and "stream" in note for note in notes)
626+
627+
628+
async def test_retry_refuses_streamed_body_idempotent_method() -> None:
629+
"""Streaming GET that hits a retryable status gets the PEP-678 note instead of retrying."""
630+
sleeper = _SleepRecorder()
631+
call_count = {"n": 0}
632+
633+
def handler(request: httpx2.Request) -> httpx2.Response:
634+
call_count["n"] += 1
635+
return httpx2.Response(HTTPStatus.SERVICE_UNAVAILABLE, request=request)
636+
637+
async def streamed_body() -> typing.AsyncIterator[bytes]:
638+
yield b"x"
639+
640+
transport = httpx2.MockTransport(handler)
641+
client = AsyncClient(
642+
httpx2_client=httpx2.AsyncClient(transport=transport),
643+
middleware=[Retry(_sleep=sleeper, base_delay=0.001, max_delay=0.002)],
644+
)
645+
646+
with pytest.raises(ServiceUnavailableError) as info:
647+
await client.put("https://example.test/data", content=streamed_body())
648+
649+
assert call_count["n"] == 1
650+
assert sleeper.calls == [] # no retry attempted
651+
notes = getattr(info.value, "__notes__", [])
652+
assert any("not retrying" in note and "stream" in note for note in notes)

0 commit comments

Comments
 (0)