Skip to content

[P1][bug] Retry: a POST refused replay after a 5xx carries no .ambiguous_write and no note (#115) #116

Description

@karlwaldman

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingpriority: highShould be fixed soon

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions