Skip to content

Commit a3b58fc

Browse files
karlwaldmanclaude
andauthored
fix(retry,errors): mark a 5xx-refused write ambiguous; stop eating the validation message (#116, #117) (#124)
Two halves of contracts shipped today that are unreachable in practice. #116 -- `.ambiguous_write` was wired only to the timeout and transport-error handlers. The 5xx branch got `False` from `should_retry`, then fell through to a bare `raise error_from_response(...)`: no flag, no note. A 502/503 from a gateway that already committed at the origin is the MORE common ambiguous outcome in production than a client-side socket timeout, and it was the one case the caller was never told about. The write was correctly sent once, so there is no duplicate -- but a caller doing the documented except OilPriceAPIError as e: if getattr(e, "ambiguous_write", False): reconcile() skipped reconciliation for exactly the case the fix was written for. Fixed at all three request helpers -- `client.request`, `client.request_with_headers`, `async_client.request` -- with a byte-identical block, so sync and async do not diverge. 4xx and 429 stay unmarked: both refused the request outright, so nothing landed. #117 -- `ValidationError.__str__` replaced the message with "Validation error for '<field>'" whenever `field` was set. `_url._reject()` always sets `field="path"`, so the four sentences of remediation added in #113 were unreachable through `str(e)`, `print(e)`, `logging.exception` and the traceback's last line; only `e.message` held them. For a non-string path (`value is None`) even the reason vanished, leaving bare "Validation error for 'path'". The message is now kept and the field/value detail appended. The historical form is unchanged when no message was supplied, so callers and tests relying on the field prefix still see it. This was never only a #113 problem -- alerts.py, diesel.py and every other caller passing both `message=` and `field=` were losing their text the same way. Tests: tests/unit/test_ambiguous_write_signal.py (12) and tests/unit/test_validation_error_message.py (8). Both proven red against `origin/main` source (13 failed / 7 passed) before the fix. Suite: 112 failed / 650 passed / 63 skipped, against a measured baseline of 112 failed / 630 passed / 63 skipped on clean main (the 112 are pre-existing, all missing pytest-asyncio and respx in the environment). Claude-Session: https://claude.ai/code/session_015ao5paex73xXvuM424Libo Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent fcc4edf commit a3b58fc

5 files changed

Lines changed: 357 additions & 8 deletions

File tree

‎oilpriceapi/async_client.py‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -285,10 +285,21 @@ async def request(
285285
)
286286
await asyncio.sleep(wait_time)
287287
continue
288-
raise error_from_response(
288+
error = error_from_response(
289289
response,
290290
commodity=params.get("commodity") if params else None,
291291
)
292+
# A 5xx is ambiguous, not a refusal: a gateway can return 502
293+
# after the origin already committed. The write was correctly
294+
# sent once and not replayed -- tell the caller the outcome is
295+
# unknown so they reconcile instead of assuming it failed
296+
# (#116). 4xx and 429 refused the request outright, so nothing
297+
# landed and there is nothing to reconcile.
298+
if response.status_code >= 500 and not self._retry_strategy.is_replay_safe(
299+
method, idempotent
300+
):
301+
raise mark_ambiguous_write(error, method)
302+
raise error
292303

293304
except httpx.TimeoutException as error:
294305
last_exception = error_from_exception(

‎oilpriceapi/client.py‎

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -328,10 +328,21 @@ def request(
328328
)
329329
time.sleep(wait_time)
330330
continue
331-
raise error_from_response(
331+
error = error_from_response(
332332
response,
333333
commodity=params.get("commodity") if params else None,
334334
)
335+
# A 5xx is ambiguous, not a refusal: a gateway can return 502
336+
# after the origin already committed. The write was correctly
337+
# sent once and not replayed -- tell the caller the outcome is
338+
# unknown so they reconcile instead of assuming it failed
339+
# (#116). 4xx and 429 refused the request outright, so nothing
340+
# landed and there is nothing to reconcile.
341+
if response.status_code >= 500 and not self._retry_strategy.is_replay_safe(
342+
method, idempotent
343+
):
344+
raise mark_ambiguous_write(error, method)
345+
raise error
335346

336347
except httpx.TimeoutException as error:
337348
last_exception = error_from_exception(
@@ -461,10 +472,21 @@ def request_with_headers(
461472
)
462473
time.sleep(wait_time)
463474
continue
464-
raise error_from_response(
475+
error = error_from_response(
465476
response,
466477
commodity=params.get("commodity") if params else None,
467478
)
479+
# A 5xx is ambiguous, not a refusal: a gateway can return 502
480+
# after the origin already committed. The write was correctly
481+
# sent once and not replayed -- tell the caller the outcome is
482+
# unknown so they reconcile instead of assuming it failed
483+
# (#116). 4xx and 429 refused the request outright, so nothing
484+
# landed and there is nothing to reconcile.
485+
if response.status_code >= 500 and not self._retry_strategy.is_replay_safe(
486+
method, idempotent
487+
):
488+
raise mark_ambiguous_write(error, method)
489+
raise error
468490

469491
except httpx.TimeoutException as error:
470492
last_exception = error_from_exception(

‎oilpriceapi/exceptions.py‎

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -333,13 +333,31 @@ def __init__(
333333
self.field = field
334334
self.value = value
335335

336+
#: Rendered when the raiser supplied no message of its own.
337+
DEFAULT_MESSAGE = "Validation error"
338+
336339
def __str__(self) -> str:
337-
message = super().__str__()
338-
if self.field:
339-
message = f"Validation error for '{self.field}'"
340+
if not self.field:
341+
return super().__str__()
342+
343+
# A supplied message is the only place the raiser's guidance lives --
344+
# the origin guard in _url.py composes four sentences of remediation --
345+
# and this method used to discard it whenever `field` was set, so no
346+
# ordinary print, log or traceback could ever show it (#117). Keep the
347+
# message and APPEND the field/value detail that other callers, and
348+
# their tests, rely on.
349+
#
350+
# Historically the field-set form carried no "[422]" prefix; keep that.
351+
if self.message in ("", self.DEFAULT_MESSAGE):
352+
detail = f"Validation error for '{self.field}'"
340353
if self.value is not None:
341-
message += f": invalid value '{self.value}'"
342-
return message
354+
detail += f": invalid value '{self.value}'"
355+
return detail
356+
357+
detail = f"{self.message} (field '{self.field}'"
358+
if self.value is not None:
359+
detail += f", invalid value '{self.value}'"
360+
return detail + ")"
343361

344362

345363
class ServerError(OilPriceAPIError):
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
"""A write whose outcome is unknown must SAY so, on every ambiguous path (#116).
2+
3+
#115 stopped replaying non-idempotent writes and promised the caller two
4+
things: the write is sent once, AND the resulting error carries
5+
``.ambiguous_write = True`` plus a note telling them to check whether it
6+
landed. Only the first half was wired to the 5xx branch.
7+
8+
A 502/503 from a gateway that already committed at the origin is the most
9+
common ambiguous outcome in production -- more common than the client-side
10+
socket timeout that was the only marked case. A caller implementing the
11+
documented contract skipped reconciliation for exactly the case the fix
12+
existed for.
13+
14+
Every assertion here is made on the exception the public method raises and on
15+
what reached the transport, never on an internal flag.
16+
"""
17+
18+
import asyncio
19+
from unittest.mock import patch
20+
21+
import httpx
22+
import pytest
23+
24+
from oilpriceapi import AsyncOilPriceAPI, OilPriceAPI
25+
from oilpriceapi.exceptions import OilPriceAPIError
26+
27+
# Not a credential: a fixture string, every request here hits a mock transport.
28+
FIXTURE_KEY = "-".join(["fixture", "not", "a", "real", "key"])
29+
30+
31+
class _Counter:
32+
def __init__(self, outcome):
33+
self.methods = []
34+
self._outcome = outcome
35+
36+
def __call__(self, request):
37+
self.methods.append(request.method)
38+
return self._outcome(request)
39+
40+
41+
def _status(code, headers=None):
42+
def handler(request):
43+
return httpx.Response(code, headers=headers or {}, json={"error": "nope"})
44+
45+
return handler
46+
47+
48+
def _sync_client(counter, **kwargs):
49+
client = OilPriceAPI(api_key=FIXTURE_KEY, **kwargs)
50+
client._client = httpx.Client(
51+
base_url=client.base_url,
52+
headers=client.headers,
53+
transport=httpx.MockTransport(counter),
54+
)
55+
return client
56+
57+
58+
def _async_client(counter, **kwargs):
59+
client = AsyncOilPriceAPI(api_key=FIXTURE_KEY, **kwargs)
60+
client._client = httpx.AsyncClient(
61+
base_url=client.base_url,
62+
headers=client.headers,
63+
transport=httpx.MockTransport(counter),
64+
)
65+
return client
66+
67+
68+
def _assert_ambiguous(error, method):
69+
assert getattr(error, "ambiguous_write", False) is True, (
70+
"the caller was not told the write outcome is unknown"
71+
)
72+
assert "NOT retried" in str(error)
73+
assert method.upper() in str(error)
74+
75+
76+
# ---------------------------------------------------------------------------
77+
# (a) A 5xx on a non-idempotent write is ambiguous and must be marked
78+
79+
80+
@pytest.mark.parametrize("code", [500, 502, 503, 504])
81+
def test_sync_post_5xx_is_marked_ambiguous(code):
82+
counter = _Counter(_status(code))
83+
client = _sync_client(counter)
84+
85+
with patch("time.sleep"):
86+
with pytest.raises(OilPriceAPIError) as excinfo:
87+
client.request("POST", "/v1/subscriptions", json_data={"plan": "x"})
88+
89+
assert counter.methods == ["POST"], "the write must still be sent exactly once"
90+
_assert_ambiguous(excinfo.value, "POST")
91+
92+
93+
def test_sync_patch_5xx_is_marked_ambiguous():
94+
counter = _Counter(_status(503))
95+
client = _sync_client(counter)
96+
97+
with patch("time.sleep"):
98+
with pytest.raises(OilPriceAPIError) as excinfo:
99+
client.request("PATCH", "/v1/alerts/1", json_data={"threshold": 1})
100+
101+
assert counter.methods == ["PATCH"]
102+
_assert_ambiguous(excinfo.value, "PATCH")
103+
104+
105+
def test_sync_request_with_headers_post_5xx_is_marked_ambiguous():
106+
counter = _Counter(_status(503))
107+
client = _sync_client(counter)
108+
109+
with patch("time.sleep"):
110+
with pytest.raises(OilPriceAPIError) as excinfo:
111+
client.request_with_headers("POST", "/v1/subscriptions", json_data={"plan": "x"})
112+
113+
assert counter.methods == ["POST"]
114+
_assert_ambiguous(excinfo.value, "POST")
115+
116+
117+
def test_async_post_5xx_is_marked_ambiguous():
118+
counter = _Counter(_status(503))
119+
120+
async def scenario():
121+
client = _async_client(counter)
122+
with pytest.raises(OilPriceAPIError) as excinfo:
123+
await client.request("POST", "/v1/subscriptions", json_data={"plan": "x"})
124+
await client._client.aclose()
125+
return excinfo.value
126+
127+
async def fake_sleep(seconds):
128+
return None
129+
130+
with patch("asyncio.sleep", fake_sleep):
131+
error = asyncio.run(scenario())
132+
133+
assert counter.methods == ["POST"]
134+
_assert_ambiguous(error, "POST")
135+
136+
137+
def test_sync_and_async_produce_the_same_signal():
138+
"""The two clients must not diverge on this contract."""
139+
sync_counter = _Counter(_status(503))
140+
with patch("time.sleep"):
141+
with pytest.raises(OilPriceAPIError) as sync_info:
142+
_sync_client(sync_counter).request("POST", "/v1/subscriptions", json_data={})
143+
144+
async_counter = _Counter(_status(503))
145+
146+
async def scenario():
147+
client = _async_client(async_counter)
148+
with pytest.raises(OilPriceAPIError) as info:
149+
await client.request("POST", "/v1/subscriptions", json_data={})
150+
await client._client.aclose()
151+
return info.value
152+
153+
async def fake_sleep(seconds):
154+
return None
155+
156+
with patch("asyncio.sleep", fake_sleep):
157+
async_error = asyncio.run(scenario())
158+
159+
assert type(sync_info.value) is type(async_error)
160+
assert str(sync_info.value) == str(async_error)
161+
assert sync_info.value.ambiguous_write == async_error.ambiguous_write is True
162+
163+
164+
# ---------------------------------------------------------------------------
165+
# (b) Everything that is NOT ambiguous must stay unmarked
166+
167+
168+
def test_sync_post_4xx_is_not_ambiguous():
169+
"""A 400 refused the write outright; nothing landed, nothing to reconcile."""
170+
counter = _Counter(_status(400))
171+
client = _sync_client(counter)
172+
173+
with patch("time.sleep"):
174+
with pytest.raises(OilPriceAPIError) as excinfo:
175+
client.request("POST", "/v1/subscriptions", json_data={"plan": "x"})
176+
177+
assert getattr(excinfo.value, "ambiguous_write", False) is False
178+
assert "NOT retried" not in str(excinfo.value)
179+
180+
181+
def test_sync_post_429_is_not_ambiguous():
182+
"""429 is an outright refusal, so the write definitively did not happen."""
183+
counter = _Counter(_status(429))
184+
client = _sync_client(counter)
185+
186+
with patch("time.sleep"):
187+
with pytest.raises(OilPriceAPIError) as excinfo:
188+
client.request("POST", "/v1/subscriptions", json_data={"plan": "x"})
189+
190+
assert counter.methods == ["POST", "POST", "POST"], "429 is safe to replay"
191+
assert getattr(excinfo.value, "ambiguous_write", False) is False
192+
193+
194+
def test_sync_get_5xx_is_not_ambiguous():
195+
counter = _Counter(_status(503))
196+
client = _sync_client(counter)
197+
198+
with patch("time.sleep"):
199+
with pytest.raises(OilPriceAPIError) as excinfo:
200+
client.request("GET", "/v1/prices/latest")
201+
202+
assert counter.methods == ["GET", "GET", "GET"]
203+
assert getattr(excinfo.value, "ambiguous_write", False) is False
204+
205+
206+
def test_sync_post_5xx_with_idempotent_true_is_retried_and_not_ambiguous():
207+
counter = _Counter(_status(503))
208+
client = _sync_client(counter)
209+
210+
with patch("time.sleep"):
211+
with pytest.raises(OilPriceAPIError) as excinfo:
212+
client.request(
213+
"POST", "/v1/diesel-prices/stations", json_data={}, idempotent=True
214+
)
215+
216+
assert counter.methods == ["POST", "POST", "POST"]
217+
assert getattr(excinfo.value, "ambiguous_write", False) is False

0 commit comments

Comments
 (0)