@@ -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