7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
366 lines
12 KiB
Python
366 lines
12 KiB
Python
"""Exponential-backoff retry wrapper for `safe_get`."""
|
|
|
|
import email.utils
|
|
from datetime import datetime, timedelta, timezone
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
import requests
|
|
|
|
from local_deep_research.security.safe_requests import (
|
|
safe_get_with_retries,
|
|
)
|
|
|
|
|
|
def _mock_response(status_code=200, headers=None, content=b""):
|
|
r = MagicMock(spec=requests.Response)
|
|
r.status_code = status_code
|
|
r.headers = headers or {}
|
|
r.content = content
|
|
return r
|
|
|
|
|
|
def _mock_response_body_raises(exc):
|
|
"""Response whose ``.content`` access raises ``exc`` on first read.
|
|
|
|
Models the real production failure where ``safe_get`` returns a
|
|
Response (headers received), then ``ChunkedEncodingError`` /
|
|
``ReadTimeout`` fires later when the body is consumed.
|
|
"""
|
|
r = MagicMock(spec=requests.Response)
|
|
r.status_code = 200
|
|
r.headers = {}
|
|
type(r).content = property(lambda self: (_ for _ in ()).throw(exc))
|
|
return r
|
|
|
|
|
|
def test_first_attempt_success_returns_immediately():
|
|
ok = _mock_response(200)
|
|
with patch(
|
|
"local_deep_research.security.safe_requests.safe_get",
|
|
return_value=ok,
|
|
) as mock_get:
|
|
resp = safe_get_with_retries("https://example.com/x")
|
|
assert resp.status_code == 200
|
|
assert mock_get.call_count == 1
|
|
|
|
|
|
def test_connection_error_retries_then_succeeds():
|
|
ok = _mock_response(200)
|
|
with (
|
|
patch(
|
|
"local_deep_research.security.safe_requests.safe_get",
|
|
side_effect=[requests.ConnectionError("boom"), ok],
|
|
) as mock_get,
|
|
patch(
|
|
"local_deep_research.security.safe_requests.time.sleep"
|
|
) as mock_sleep,
|
|
):
|
|
resp = safe_get_with_retries(
|
|
"https://example.com/x", backoff_times=(0, 0, 0)
|
|
)
|
|
assert resp.status_code == 200
|
|
assert mock_get.call_count == 2
|
|
assert mock_sleep.called
|
|
|
|
|
|
def test_timeout_retries_then_gives_up():
|
|
with (
|
|
patch(
|
|
"local_deep_research.security.safe_requests.safe_get",
|
|
side_effect=requests.Timeout("slow"),
|
|
) as mock_get,
|
|
patch("local_deep_research.security.safe_requests.time.sleep"),
|
|
):
|
|
with pytest.raises(requests.Timeout):
|
|
safe_get_with_retries(
|
|
"https://example.com/x",
|
|
max_retries=2,
|
|
backoff_times=(0, 0, 0),
|
|
)
|
|
# 1 initial + 2 retries = 3 attempts total
|
|
assert mock_get.call_count == 3
|
|
|
|
|
|
def test_http_500_retries_then_succeeds():
|
|
bad = _mock_response(500)
|
|
ok = _mock_response(200)
|
|
with (
|
|
patch(
|
|
"local_deep_research.security.safe_requests.safe_get",
|
|
side_effect=[bad, ok],
|
|
) as mock_get,
|
|
patch("local_deep_research.security.safe_requests.time.sleep"),
|
|
):
|
|
resp = safe_get_with_retries(
|
|
"https://example.com/x", backoff_times=(0, 0, 0)
|
|
)
|
|
assert resp.status_code == 200
|
|
assert mock_get.call_count == 2
|
|
|
|
|
|
def test_http_429_honors_retry_after():
|
|
rate_limited = _mock_response(429, headers={"Retry-After": "7"})
|
|
ok = _mock_response(200)
|
|
with (
|
|
patch(
|
|
"local_deep_research.security.safe_requests.safe_get",
|
|
side_effect=[rate_limited, ok],
|
|
),
|
|
patch(
|
|
"local_deep_research.security.safe_requests.time.sleep"
|
|
) as mock_sleep,
|
|
):
|
|
safe_get_with_retries("https://example.com/x", backoff_times=(1, 1, 1))
|
|
# Retry-After was 7, must override the 1-second schedule.
|
|
assert mock_sleep.call_args[0][0] == 7
|
|
|
|
|
|
def test_ssrf_validation_error_is_not_retried():
|
|
"""ValueError means SSRF rejection — retrying would just re-fail."""
|
|
with (
|
|
patch(
|
|
"local_deep_research.security.safe_requests.safe_get",
|
|
side_effect=ValueError("URL failed security validation"),
|
|
) as mock_get,
|
|
patch("local_deep_research.security.safe_requests.time.sleep"),
|
|
):
|
|
with pytest.raises(ValueError):
|
|
safe_get_with_retries(
|
|
"http://169.254.169.254/", backoff_times=(0, 0, 0)
|
|
)
|
|
assert mock_get.call_count == 1
|
|
|
|
|
|
def test_http_404_is_not_retried():
|
|
"""4xx (other than 429) is caller's fault — no retry."""
|
|
not_found = _mock_response(404)
|
|
with (
|
|
patch(
|
|
"local_deep_research.security.safe_requests.safe_get",
|
|
return_value=not_found,
|
|
) as mock_get,
|
|
patch("local_deep_research.security.safe_requests.time.sleep"),
|
|
):
|
|
resp = safe_get_with_retries(
|
|
"https://example.com/x", backoff_times=(0, 0, 0)
|
|
)
|
|
assert resp.status_code == 404
|
|
assert mock_get.call_count == 1
|
|
|
|
|
|
def test_retry_after_integer_is_capped_at_max():
|
|
"""A hostile Retry-After: 86400 must not pin the worker for a day."""
|
|
rate_limited = _mock_response(429, headers={"Retry-After": "86400"})
|
|
ok = _mock_response(200)
|
|
with (
|
|
patch(
|
|
"local_deep_research.security.safe_requests.safe_get",
|
|
side_effect=[rate_limited, ok],
|
|
),
|
|
patch(
|
|
"local_deep_research.security.safe_requests.time.sleep"
|
|
) as mock_sleep,
|
|
):
|
|
safe_get_with_retries("https://example.com/x", backoff_times=(1, 1, 1))
|
|
assert mock_sleep.call_args[0][0] == 300
|
|
|
|
|
|
def test_retry_after_http_date_form_is_parsed():
|
|
"""RFC 7231 HTTP-date form must be parsed, not silently ignored."""
|
|
future = datetime.now(timezone.utc) + timedelta(seconds=60)
|
|
http_date = email.utils.formatdate(future.timestamp(), usegmt=True)
|
|
rate_limited = _mock_response(429, headers={"Retry-After": http_date})
|
|
ok = _mock_response(200)
|
|
with (
|
|
patch(
|
|
"local_deep_research.security.safe_requests.safe_get",
|
|
side_effect=[rate_limited, ok],
|
|
),
|
|
patch(
|
|
"local_deep_research.security.safe_requests.time.sleep"
|
|
) as mock_sleep,
|
|
):
|
|
safe_get_with_retries("https://example.com/x", backoff_times=(1, 1, 1))
|
|
slept = mock_sleep.call_args[0][0]
|
|
# 30s band absorbs clock jitter and test-harness latency.
|
|
assert 45 <= slept <= 75, f"expected ~60s, got {slept}s"
|
|
|
|
|
|
def test_retry_after_unparseable_falls_back_to_schedule():
|
|
"""Garbage Retry-After falls back to the backoff schedule."""
|
|
rate_limited = _mock_response(429, headers={"Retry-After": "garbage"})
|
|
ok = _mock_response(200)
|
|
with (
|
|
patch(
|
|
"local_deep_research.security.safe_requests.safe_get",
|
|
side_effect=[rate_limited, ok],
|
|
),
|
|
patch(
|
|
"local_deep_research.security.safe_requests.time.sleep"
|
|
) as mock_sleep,
|
|
):
|
|
safe_get_with_retries("https://example.com/x", backoff_times=(1, 2, 4))
|
|
assert mock_sleep.call_args[0][0] == 1
|
|
|
|
|
|
def test_retry_after_negative_integer_is_clamped_to_zero():
|
|
"""time.sleep(-5) raises; Retry-After: -5 must be clamped to 0."""
|
|
rate_limited = _mock_response(429, headers={"Retry-After": "-5"})
|
|
ok = _mock_response(200)
|
|
with (
|
|
patch(
|
|
"local_deep_research.security.safe_requests.safe_get",
|
|
side_effect=[rate_limited, ok],
|
|
),
|
|
patch(
|
|
"local_deep_research.security.safe_requests.time.sleep"
|
|
) as mock_sleep,
|
|
):
|
|
safe_get_with_retries("https://example.com/x", backoff_times=(1, 1, 1))
|
|
assert mock_sleep.call_args[0][0] == 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# consume_body=True: retry on body-stream transients
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_consume_body_default_does_not_read_body():
|
|
"""Default (``consume_body=False``) must not touch ``response.content``.
|
|
|
|
The wrapper has historically returned the response without reading
|
|
the body. Callers that stream large bodies (e.g., NDJSON line-by-line)
|
|
rely on this — a regression that pre-reads would balloon memory.
|
|
"""
|
|
body_marker = MagicMock()
|
|
body_marker.__bool__ = MagicMock(side_effect=AssertionError("touched"))
|
|
resp = _mock_response(200)
|
|
type(resp).content = property(lambda self: body_marker)
|
|
|
|
with patch(
|
|
"local_deep_research.security.safe_requests.safe_get",
|
|
return_value=resp,
|
|
):
|
|
# Should not raise — default behavior leaves .content untouched.
|
|
safe_get_with_retries("https://example.com/x")
|
|
|
|
|
|
def test_consume_body_retries_on_chunked_encoding_error():
|
|
"""Mid-stream ``ChunkedEncodingError`` retries the whole fetch."""
|
|
bad = _mock_response_body_raises(
|
|
requests.exceptions.ChunkedEncodingError(
|
|
"IncompleteRead(835082 bytes read, 1262437 more expected)"
|
|
)
|
|
)
|
|
ok = _mock_response(200, content=b"final")
|
|
with (
|
|
patch(
|
|
"local_deep_research.security.safe_requests.safe_get",
|
|
side_effect=[bad, ok],
|
|
) as mock_get,
|
|
patch("local_deep_research.security.safe_requests.time.sleep"),
|
|
):
|
|
resp = safe_get_with_retries(
|
|
"https://example.com/x",
|
|
consume_body=True,
|
|
backoff_times=(0, 0, 0),
|
|
)
|
|
assert resp.content == b"final"
|
|
assert mock_get.call_count == 2
|
|
bad.close.assert_called_once()
|
|
|
|
|
|
def test_consume_body_retries_on_read_timeout():
|
|
"""``ReadTimeout`` (Timeout but NOT ConnectionError) is also retried.
|
|
|
|
Pinning this explicitly: the obvious ``except ConnectionError``
|
|
catch would silently miss ReadTimeout (it's a Timeout subclass,
|
|
not a ConnectionError subclass), so the implementation must list
|
|
``Timeout`` separately.
|
|
"""
|
|
bad = _mock_response_body_raises(
|
|
requests.exceptions.ReadTimeout("body read stalled")
|
|
)
|
|
ok = _mock_response(200, content=b"final")
|
|
with (
|
|
patch(
|
|
"local_deep_research.security.safe_requests.safe_get",
|
|
side_effect=[bad, ok],
|
|
) as mock_get,
|
|
patch("local_deep_research.security.safe_requests.time.sleep"),
|
|
):
|
|
resp = safe_get_with_retries(
|
|
"https://example.com/x",
|
|
consume_body=True,
|
|
backoff_times=(0, 0, 0),
|
|
)
|
|
assert resp.content == b"final"
|
|
assert mock_get.call_count == 2
|
|
|
|
|
|
def test_consume_body_gives_up_after_max_retries():
|
|
"""Persistent body-read failures eventually surface to the caller."""
|
|
bad = _mock_response_body_raises(
|
|
requests.exceptions.ChunkedEncodingError("broken")
|
|
)
|
|
with (
|
|
patch(
|
|
"local_deep_research.security.safe_requests.safe_get",
|
|
return_value=bad,
|
|
) as mock_get,
|
|
patch("local_deep_research.security.safe_requests.time.sleep"),
|
|
):
|
|
with pytest.raises(requests.exceptions.ChunkedEncodingError):
|
|
safe_get_with_retries(
|
|
"https://example.com/x",
|
|
consume_body=True,
|
|
max_retries=2,
|
|
backoff_times=(0, 0, 0),
|
|
)
|
|
# 1 initial + 2 retries = 3 attempts
|
|
assert mock_get.call_count == 3
|
|
|
|
|
|
def test_consume_body_does_not_retry_value_error_from_body_guard():
|
|
"""``ValueError`` from oversized-body guard must NOT be retried.
|
|
|
|
A 1 GB+ response isn't a transient network error — retrying just
|
|
burns more bandwidth on the same outcome. The guard's ValueError
|
|
must propagate immediately on the first attempt.
|
|
"""
|
|
bad = _mock_response_body_raises(
|
|
ValueError("Response body too large: >1073741825 bytes")
|
|
)
|
|
with (
|
|
patch(
|
|
"local_deep_research.security.safe_requests.safe_get",
|
|
return_value=bad,
|
|
) as mock_get,
|
|
patch("local_deep_research.security.safe_requests.time.sleep"),
|
|
):
|
|
with pytest.raises(ValueError):
|
|
safe_get_with_retries(
|
|
"https://example.com/x",
|
|
consume_body=True,
|
|
backoff_times=(0, 0, 0),
|
|
)
|
|
# Single attempt — no retry on ValueError.
|
|
assert mock_get.call_count == 1
|
|
|
|
|
|
def test_consume_body_returns_cached_body_to_caller():
|
|
"""After a successful retry, ``response.content`` is cached.
|
|
|
|
The point of consuming inside the loop is so the caller doesn't
|
|
repeat the read (and risk a second transient). Verify the body
|
|
is available without a second read.
|
|
"""
|
|
ok = _mock_response(200, content=b"hello world")
|
|
with patch(
|
|
"local_deep_research.security.safe_requests.safe_get",
|
|
return_value=ok,
|
|
):
|
|
resp = safe_get_with_retries("https://example.com/x", consume_body=True)
|
|
assert resp.content == b"hello world"
|