diff --git a/src/benchmark_radar/http.py b/src/benchmark_radar/http.py index 176d1c45..fe1a782e 100644 --- a/src/benchmark_radar/http.py +++ b/src/benchmark_radar/http.py @@ -165,7 +165,14 @@ def _request( delay = float(2**attempt) if attempt + 1 < attempts: time.sleep(min(delay, MAX_RETRY_DELAY_SECONDS)) - except (urllib.error.URLError, TimeoutError) as error: + # A connection can drop after headers arrive. Retry the body read too, + # and report only the exception type so partial payloads stay private. + except ( + urllib.error.URLError, + TimeoutError, + ConnectionError, + http.client.IncompleteRead, + ) as error: last_error = error if attempt + 1 < attempts: time.sleep(min(2**attempt, MAX_RETRY_DELAY_SECONDS)) diff --git a/tests/test_http.py b/tests/test_http.py index cff1ab1d..59081289 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -265,3 +265,51 @@ def fake_urlopen(request, **kwargs): assert captured["request"].data == b'{"input":"brief me"}' assert captured["request"].get_header("Content-type") == "application/json" assert captured["request"].get_header("Authorization") == "Bearer secret" + + +@pytest.mark.parametrize( + "failure", [http.client.IncompleteRead(b"private-body"), ConnectionResetError("private-body")] +) +def test_http_retries_interrupted_response_reads(monkeypatch, failure): + calls = [] + closed = [] + sleeps = [] + + class InterruptedResponse(Response): + def read(self): + raise failure + + def __exit__(self, *args): + closed.append(True) + return False + + def fake_urlopen(request, **kwargs): + calls.append(request.full_url) + return InterruptedResponse(b"") if len(calls) == 1 else Response(b'{"ok": true}') + + monkeypatch.setattr("benchmark_radar.http.urllib.request.urlopen", fake_urlopen) + monkeypatch.setattr("benchmark_radar.http.time.sleep", sleeps.append) + # A dropped connection after headers used to bypass retries entirely. + assert get_json("https://example.test/data?key=private", attempts=2) == {"ok": True} + assert len(calls) == 2 + assert closed == [True] + assert sleeps == [1] + + +@pytest.mark.parametrize( + "failure", [http.client.IncompleteRead(b"private-body"), ConnectionResetError("private-body")] +) +def test_http_interrupted_read_exhaustion_is_credential_safe(monkeypatch, failure): + class InterruptedResponse(Response): + def read(self): + raise failure + + monkeypatch.setattr( + "benchmark_radar.http.urllib.request.urlopen", + lambda *args, **kwargs: InterruptedResponse(b""), + ) + monkeypatch.setattr("benchmark_radar.http.time.sleep", lambda seconds: None) + with pytest.raises(RequestError) as captured: + get_json("https://example.test/data?key=private", attempts=2) + assert "private" not in str(captured.value) + assert "after 2 attempts" in str(captured.value)