Merged in #115 (fcc4edf). Verified by execution against main on 2026-09-13 with CPython 3.14 / httpx 0.28.1 and a mock transport — confirmed live.
What is wrong
The #115 commit message states:
When a write is not replayed the error says so and carries .ambiguous_write = True, so the caller knows to check whether it landed rather than blindly resending.
That is wired into the httpx.TimeoutException and httpx.RequestError handlers only. The 5xx branch calls should_retry(...), gets False from the new is_replay_safe check, and falls straight through to raise error_from_response(...) with no marking and no note.
oilpriceapi/client.py:314-331 (request)
oilpriceapi/client.py:447-464 (request_with_headers)
oilpriceapi/async_client.py:271-288 (request)
All three are affected identically — this is not a sync/async divergence.
Repro
import httpx, time
from oilpriceapi import OilPriceAPI
time.sleep = lambda s: None # keep the repro fast
FIXTURE_KEY = "fixture-not-a-real-credential"
def handler(request):
handler.n += 1
return httpx.Response(503, json={"status": "error"})
handler.n = 0
c = OilPriceAPI(api_key=FIXTURE_KEY)
c._client = httpx.Client(base_url=c.base_url, headers=c.headers,
transport=httpx.MockTransport(handler))
try:
c.request("POST", "/v1/subscriptions", json_data={"plan": "x"})
except Exception as e:
print(type(e).__name__, "ambiguous_write =", getattr(e, "ambiguous_write", None))
Measured:
sync POST timeout attempts=1 -> TimeoutError ambiguous=True
sync POST 503 attempts=1 -> ServerError ambiguous=None <-- no signal
async POST 503 attempts=1 -> ServerError ambiguous=None <-- no signal
Why it matters
A 502/503 from a gateway that already committed at the origin is the most common ambiguous outcome in production — more common than a client-side socket timeout, which is the only case currently marked. subscriptions.create() and webhooks.create() go through these helpers.
A caller who implements the documented contract:
try:
client.subscriptions.create(...)
except OilPriceAPIError as e:
if getattr(e, "ambiguous_write", False):
reconcile() # never runs for a 503
silently skips reconciliation for exactly the case the fix was written for. The write is correctly not replayed, so there is no duplicate — but the caller is not told the outcome is unknown, which is the other half of the contract.
Suggested fix
In each 5xx branch, when should_retry returned False because the method is not replay-safe, route the response error through mark_ambiguous_write(...) before raising:
error = error_from_response(response, ...)
if not self._retry_strategy.is_replay_safe(method, idempotent):
raise mark_ambiguous_write(error, method)
raise error
mark_ambiguous_write's current wording ("This POST was NOT retried…") reads correctly for a 5xx too.
Add a test in the shape of tests/unit/test_write_replay_and_retry_config.py asserting ambiguous_write is True for POST + 503, for all three request helpers.
Merged in #115 (
fcc4edf). Verified by execution againstmainon 2026-09-13 with CPython 3.14 / httpx 0.28.1 and a mock transport — confirmed live.What is wrong
The #115 commit message states:
That is wired into the
httpx.TimeoutExceptionandhttpx.RequestErrorhandlers only. The 5xx branch callsshould_retry(...), getsFalsefrom the newis_replay_safecheck, and falls straight through toraise error_from_response(...)with no marking and no note.oilpriceapi/client.py:314-331(request)oilpriceapi/client.py:447-464(request_with_headers)oilpriceapi/async_client.py:271-288(request)All three are affected identically — this is not a sync/async divergence.
Repro
Measured:
Why it matters
A 502/503 from a gateway that already committed at the origin is the most common ambiguous outcome in production — more common than a client-side socket timeout, which is the only case currently marked.
subscriptions.create()andwebhooks.create()go through these helpers.A caller who implements the documented contract:
silently skips reconciliation for exactly the case the fix was written for. The write is correctly not replayed, so there is no duplicate — but the caller is not told the outcome is unknown, which is the other half of the contract.
Suggested fix
In each 5xx branch, when
should_retryreturnedFalsebecause the method is not replay-safe, route the response error throughmark_ambiguous_write(...)before raising:mark_ambiguous_write's current wording ("This POST was NOT retried…") reads correctly for a 5xx too.Add a test in the shape of
tests/unit/test_write_replay_and_retry_config.pyassertingambiguous_write is Truefor POST + 503, for all three request helpers.