@@ -807,3 +807,109 @@ def counting() -> str:
807807
808808 assert _bearer_from (auth ) == "Bearer eyJ.once.jwt"
809809 assert count ["n" ] == 1
810+
811+
812+ # --------------------------------------------------------------------------
813+ # Item 9 -- token-exchange success-path body-read retry
814+ #
815+ # A connection dropped while reading the body of a 200 is a transport-level,
816+ # transient failure and must be retried within the attempt budget, exactly like
817+ # a pre-response connection error. A valid-but-malformed JSON body, in contrast,
818+ # is a definitive rejection and must NOT be retried. The truncated-body case
819+ # genuinely requires a raw socket: no fake pool can send a valid 200 status line
820+ # and then drop mid-body, which is the precise condition under test.
821+ # --------------------------------------------------------------------------
822+
823+
824+ def _raw_token_server (bodies : List [Optional [bytes ]]):
825+ """Start a raw TCP server that serves one scripted response per connection.
826+
827+ Each element of ``bodies`` is the raw bytes to send for that connection
828+ (already including status line + headers), or ``None`` to send a truncated
829+ 200 -- a Content-Length promising more than is sent, then an immediate close.
830+ Returns ``(port, accepted)`` where ``accepted["n"]`` counts connections.
831+ """
832+ import socket
833+
834+ srv = socket .socket (socket .AF_INET , socket .SOCK_STREAM )
835+ srv .setsockopt (socket .SOL_SOCKET , socket .SO_REUSEADDR , 1 )
836+ srv .bind (("127.0.0.1" , 0 ))
837+ srv .listen (len (bodies ) + 1 )
838+ port = srv .getsockname ()[1 ]
839+ accepted = {"n" : 0 }
840+
841+ def serve () -> None :
842+ for raw in bodies :
843+ try :
844+ conn , _ = srv .accept ()
845+ except OSError :
846+ break
847+ accepted ["n" ] += 1
848+ try :
849+ conn .recv (65536 ) # drain the request
850+ if raw is None :
851+ # 200 promising 100 body bytes, then only a few + close.
852+ conn .sendall (
853+ b"HTTP/1.1 200 OK\r \n Content-Length: 100\r \n \r \n {\" access"
854+ )
855+ else :
856+ conn .sendall (raw )
857+ except OSError :
858+ pass
859+ finally :
860+ try :
861+ conn .close ()
862+ except OSError :
863+ pass
864+ try :
865+ srv .close ()
866+ except OSError :
867+ pass
868+
869+ threading .Thread (target = serve , daemon = True ).start ()
870+ return port , accepted
871+
872+
873+ def _full_token_response (access_token : str = "eyJ.real.jwt" ) -> bytes :
874+ payload = json .dumps (
875+ {"access_token" : access_token , "token_type" : "Bearer" , "expires_in" : 300 }
876+ ).encode ()
877+ return (
878+ b"HTTP/1.1 200 OK\r \n Content-Length: %d\r \n \r \n %b" % (len (payload ), payload )
879+ )
880+
881+
882+ def test_truncated_200_body_is_retried_then_succeeds () -> None :
883+ """A 200 whose body is truncated mid-read (connection dropped) is transient:
884+ the exchange retries on a fresh connection and returns the token.
885+
886+ Uses a real socket + real PoolManager -- the whole point is a genuine
887+ valid-status-line-then-truncated-body transport failure, which surfaces as a
888+ urllib3 ``ProtocolError`` from ``pool.request()`` (the body is preloaded) and
889+ is caught + retried by ``_exchange``'s transport-error branch.
890+ """
891+ sleeps : List [float ] = []
892+ port , accepted = _raw_token_server ([None , _full_token_response ("eyJ.real.jwt" )])
893+ mgr = _TokenManager (
894+ "hd_secret_token" ,
895+ _config (host = f"http://127.0.0.1:{ port } " ),
896+ pool = urllib3 .PoolManager (),
897+ sleep = sleeps .append ,
898+ )
899+
900+ assert mgr .bearer_value () == "eyJ.real.jwt"
901+ assert accepted ["n" ] == 2 # first (truncated) retried on a second connection
902+ assert len (sleeps ) == 1
903+
904+
905+ def test_malformed_json_200_body_is_not_retried () -> None :
906+ """A complete 200 whose body is valid bytes but invalid JSON is a definitive
907+ rejection -- it must NOT be retried, and must surface as TokenExchangeError."""
908+ sleeps : List [float ] = []
909+ pool = _FakePool ([_FakeResponse (200 , b"{ not valid json " )])
910+ mgr = _TokenManager ("hd_secret_token" , _config (), pool = pool , sleep = sleeps .append )
911+
912+ with pytest .raises (TokenExchangeError ):
913+ mgr .bearer_value ()
914+ assert len (pool .calls ) == 1 # parse error is fatal, not retried
915+ assert sleeps == []
0 commit comments