|
| 1 | +"""The remaining #123 findings: typed errors, a real 60s cap, honest status. |
| 2 | +
|
| 3 | +Three separate promises the code does not keep: |
| 4 | +
|
| 5 | +1. ``resolve_api_url``'s docstring says it raises ``ValidationError``. It leaks |
| 6 | + a raw stdlib ``ValueError`` for a base URL with an out-of-range port or a |
| 7 | + non-ASCII netloc whose NFKC normalisation introduces one of ``/?#@:``. |
| 8 | + ``_origin`` runs on ``base_url`` on EVERY request, so this is on the hot path. |
| 9 | +2. ``calculate_wait_time``'s docstring says "capped at 60 seconds". With jitter |
| 10 | + it returns up to 78s, and only the 429/``Retry-After`` path was bounded by |
| 11 | + #115 -- the 5xx and transport-error paths call it raw. |
| 12 | +3. ``ValidationError`` hard-codes ``status_code=422``, so a purely local guard |
| 13 | + refusal -- no request ever sent -- reports an HTTP status and |
| 14 | + ``is_client_error is True``. Anything aggregating by status records a 422 |
| 15 | + for a request that never reached the network. |
| 16 | +""" |
| 17 | + |
| 18 | +from unittest.mock import Mock, patch |
| 19 | + |
| 20 | +import pytest |
| 21 | + |
| 22 | +from oilpriceapi._url import resolve_api_url |
| 23 | +from oilpriceapi.exceptions import OilPriceAPIError, ValidationError |
| 24 | +from oilpriceapi.retry import RetryStrategy |
| 25 | + |
| 26 | +BASE = "https://api.oilpriceapi.com" |
| 27 | + |
| 28 | +# Not a credential: a fixture string, every request here is mocked. |
| 29 | +FIXTURE_KEY = "-".join(["fixture", "not", "a", "real", "key"]) |
| 30 | + |
| 31 | +# urlsplit raises ValueError on both of these. |
| 32 | +UNPARSEABLE_BASES = [ |
| 33 | + "https://api.oilpriceapi.com:99999", |
| 34 | + "https://℀evil.example", |
| 35 | +] |
| 36 | + |
| 37 | + |
| 38 | +# --- 1. no raw ValueError escapes the documented contract ------------------- |
| 39 | + |
| 40 | +@pytest.mark.parametrize("base", UNPARSEABLE_BASES) |
| 41 | +def test_resolve_api_url_raises_typed_error_not_valueerror(base): |
| 42 | + with pytest.raises(OilPriceAPIError): |
| 43 | + resolve_api_url(base, "/v1/prices") |
| 44 | + |
| 45 | + |
| 46 | +@pytest.mark.parametrize("base", UNPARSEABLE_BASES) |
| 47 | +def test_resolve_api_url_never_leaks_bare_valueerror(base): |
| 48 | + """A bare ValueError is exactly what the docstring says cannot happen.""" |
| 49 | + try: |
| 50 | + resolve_api_url(base, "/v1/prices") |
| 51 | + except OilPriceAPIError: |
| 52 | + pass |
| 53 | + except ValueError as exc: # pragma: no cover - this is the bug |
| 54 | + pytest.fail(f"leaked a raw ValueError: {exc}") |
| 55 | + |
| 56 | + |
| 57 | +@pytest.mark.parametrize("path", ["/v1/prices:99999", "/v1/℀prices"]) |
| 58 | +def test_unusual_paths_against_a_sane_base_still_resolve(path): |
| 59 | + """The fix must not start refusing ordinary paths.""" |
| 60 | + assert resolve_api_url(BASE, path).startswith(BASE) |
| 61 | + |
| 62 | + |
| 63 | +# --- 2. the documented 60s cap is a real cap -------------------------------- |
| 64 | + |
| 65 | +def test_calculate_wait_time_never_exceeds_the_documented_cap(): |
| 66 | + s = RetryStrategy(max_retries=20) |
| 67 | + # 2**10 = 1024 -> base 60 -> +30% jitter = up to 78s today. |
| 68 | + worst = max(s.calculate_wait_time(10) for _ in range(2000)) |
| 69 | + assert worst <= RetryStrategy.MAX_WAIT_SECONDS, worst |
| 70 | + |
| 71 | + |
| 72 | +@pytest.mark.parametrize("attempt", range(0, 14)) |
| 73 | +def test_calculate_wait_time_is_bounded_at_every_attempt(attempt): |
| 74 | + s = RetryStrategy(max_retries=20) |
| 75 | + for _ in range(100): |
| 76 | + w = s.calculate_wait_time(attempt) |
| 77 | + assert 0.0 <= w <= RetryStrategy.MAX_WAIT_SECONDS, (attempt, w) |
| 78 | + |
| 79 | + |
| 80 | +def test_calculate_wait_time_keeps_jitter_below_the_cap(): |
| 81 | + """Bounding must not collapse jitter into a constant and re-create the |
| 82 | + thundering herd the jitter exists to prevent.""" |
| 83 | + s = RetryStrategy(max_retries=20) |
| 84 | + small = {round(s.calculate_wait_time(1), 6) for _ in range(200)} |
| 85 | + assert len(small) > 1, "jitter disappeared at an unsaturated attempt" |
| 86 | + |
| 87 | + |
| 88 | +def test_calculate_wait_time_without_jitter_is_unchanged(): |
| 89 | + s = RetryStrategy(max_retries=20, jitter=False) |
| 90 | + assert s.calculate_wait_time(0) == 1 |
| 91 | + assert s.calculate_wait_time(2) == 4 |
| 92 | + assert s.calculate_wait_time(10) == RetryStrategy.MAX_WAIT_SECONDS |
| 93 | + |
| 94 | + |
| 95 | +def test_docstring_cap_matches_the_constant(): |
| 96 | + doc = RetryStrategy.calculate_wait_time.__doc__ or "" |
| 97 | + assert str(int(RetryStrategy.MAX_WAIT_SECONDS)) in doc |
| 98 | + |
| 99 | + |
| 100 | +# --- 3. a local refusal carries no HTTP status ------------------------------ |
| 101 | + |
| 102 | +def test_local_guard_refusal_has_no_http_status(): |
| 103 | + """No request was sent, so there is no status code to report.""" |
| 104 | + with pytest.raises(ValidationError) as exc: |
| 105 | + resolve_api_url(BASE, "//evil.example/v1/prices") |
| 106 | + assert exc.value.status_code is None |
| 107 | + |
| 108 | + |
| 109 | +def test_local_guard_refusal_is_not_a_client_http_error(): |
| 110 | + with pytest.raises(ValidationError) as exc: |
| 111 | + resolve_api_url(BASE, "//evil.example/v1/prices") |
| 112 | + assert exc.value.is_client_error is False |
| 113 | + |
| 114 | + |
| 115 | +def test_server_sent_validation_error_keeps_422(): |
| 116 | + """A real 422 from the API must still report 422.""" |
| 117 | + err = ValidationError("bad field", field="code", status_code=422) |
| 118 | + assert err.status_code == 422 |
| 119 | + assert err.is_client_error is True |
| 120 | + |
| 121 | + |
| 122 | +def test_validation_error_default_is_still_422_for_existing_callers(): |
| 123 | + """Back-compat: callers that construct it bare still get the HTTP default.""" |
| 124 | + assert ValidationError("bad").status_code == 422 |
| 125 | + |
| 126 | + |
| 127 | +# --- 4. sync and async must not diverge ------------------------------------- |
| 128 | + |
| 129 | +def _server_error(): |
| 130 | + """A 503 the retry strategy will keep retrying.""" |
| 131 | + response = Mock() |
| 132 | + response.status_code = 503 |
| 133 | + response.headers = {} |
| 134 | + response.json.return_value = {"error": "unavailable"} |
| 135 | + response.text = "unavailable" |
| 136 | + return response |
| 137 | + |
| 138 | + |
| 139 | +@patch("httpx.Client.request") |
| 140 | +def test_sync_client_never_sleeps_past_the_cap_on_5xx(mock_request, monkeypatch): |
| 141 | + """The 5xx path, which #115 left unbounded, on the real transport.""" |
| 142 | + from oilpriceapi import OilPriceAPI |
| 143 | + |
| 144 | + mock_request.return_value = _server_error() |
| 145 | + sleeps = [] |
| 146 | + monkeypatch.setattr("time.sleep", lambda s: sleeps.append(s)) |
| 147 | + |
| 148 | + c = OilPriceAPI(api_key=FIXTURE_KEY, base_url=BASE, max_retries=14) |
| 149 | + with pytest.raises(Exception): |
| 150 | + c.request("GET", "/v1/prices/latest") |
| 151 | + |
| 152 | + assert sleeps, "no retry happened; the test proves nothing" |
| 153 | + assert max(sleeps) <= RetryStrategy.MAX_WAIT_SECONDS, max(sleeps) |
| 154 | + |
| 155 | + |
| 156 | +@pytest.mark.asyncio |
| 157 | +@patch("httpx.AsyncClient.request") |
| 158 | +async def test_async_client_never_sleeps_past_the_cap_on_5xx(mock_request, monkeypatch): |
| 159 | + """Parity: identical assertion against the async client's 5xx path.""" |
| 160 | + import asyncio |
| 161 | + |
| 162 | + from oilpriceapi import AsyncOilPriceAPI |
| 163 | + |
| 164 | + mock_request.return_value = _server_error() |
| 165 | + sleeps = [] |
| 166 | + |
| 167 | + async def fake_sleep(s): |
| 168 | + sleeps.append(s) |
| 169 | + |
| 170 | + monkeypatch.setattr(asyncio, "sleep", fake_sleep) |
| 171 | + |
| 172 | + c = AsyncOilPriceAPI(api_key=FIXTURE_KEY, base_url=BASE, max_retries=14) |
| 173 | + with pytest.raises(Exception): |
| 174 | + await c.request("GET", "/v1/prices/latest") |
| 175 | + |
| 176 | + assert sleeps, "no retry happened; the test proves nothing" |
| 177 | + assert max(sleeps) <= RetryStrategy.MAX_WAIT_SECONDS, max(sleeps) |
| 178 | + |
| 179 | + |
| 180 | +def test_both_clients_have_the_same_number_of_wait_call_sites_per_method(): |
| 181 | + """Structural pin: neither client may grow an unbounded wait the other |
| 182 | + lacks. Both route every wait through the one bounded calculate_wait_time.""" |
| 183 | + import inspect |
| 184 | + |
| 185 | + from oilpriceapi import async_client, client |
| 186 | + |
| 187 | + sync_src = inspect.getsource(client) |
| 188 | + async_src = inspect.getsource(async_client) |
| 189 | + # Every wait in both clients comes from the shared, now-bounded strategy. |
| 190 | + assert "calculate_wait_time" in sync_src |
| 191 | + assert "calculate_wait_time" in async_src |
| 192 | + for src, name in ((sync_src, "client"), (async_src, "async_client")): |
| 193 | + assert "2 **" not in src, f"{name} computes its own backoff" |
| 194 | + assert "random.uniform" not in src, f"{name} adds its own jitter" |
0 commit comments