Skip to content

Commit 5dba9fc

Browse files
committed
test(auth): cover token-exchange body-read retry
1 parent 7e72d74 commit 5dba9fc

1 file changed

Lines changed: 106 additions & 0 deletions

File tree

‎tests/test_auth.py‎

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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\nContent-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\nContent-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

Comments
 (0)