Non-idempotent upstreams⌗
Some upstreams cannot be made idempotent. Wires, certain payment rails, calls to brittle vendors that re-process on every POST. Tape's design point is that these are honest about it — and that means three things must be true before the dispatch happens:
- The intent is journaled. The agent's tool body returns the intent; it does NOT perform IO.
- A reactor owns the dispatch. The reactor is the only thing that actually calls the upstream.
- The intent declares how to resolve UNKNOWN —
at least one of:
business_key=,status_check=, orcompensate=.
Before you start
If you haven't met UNKNOWN as a first-class outcome yet, read
UNKNOWN — the third outcome first. It's
one page and it'll make this one make sense.
@tape.outbox_tool(...) (sugar for @tape.effect(dispatch="outbox", ...))
enforces this at decoration time. A non-idempotent outbox tool without any of
those three raises ValueError (override with allow_unsafe=True after explicit
review). The server also enforces the contract at BeginEffect-time so even an
older SDK can't slip through.
@tape.outbox_tool(
connector="bank.wire",
business_key=lambda account, amount, date, **_: f"{account}:{amount}:{date}",
status_check=find_wire,
compensate=reverse_wire,
)
def wire_money(account, amount, beneficiary, date):
return {"account": account, "amount": amount,
"beneficiary": beneficiary, "date": date}
The state machine⌗
intent journaled ──► outbox reactor: dispatch
│
┌───────────────┼───────────────────────┐
▼ ▼ ▼
CONFIRMED UNKNOWN FAILED
│ │
observe() returns no retry
│ (4xx-class)
┌───────────────────┼───────────────────────┐
▼ ▼ ▼
CONFIRMED ABSENT DUPLICATE
(we're good) (re-dispatch) ↓ compensate
obligation
enqueued
The outbox reactor never retries a non-idempotent dispatch blindly. After an
UNKNOWN, it calls connector.observe(effect). observe() is what the
counterparty's API looks like through your lens — query by business key,
search by idempotency key, whatever maps to your upstream.
If observe() returns DUPLICATE (a second wire really did happen), the
reactor registers a compensation obligation and the compensation reactor
calls connector.compensate(obligation) to reverse it. If compensate() is
unavailable or fails, the run becomes STUCK and a human gets paged.
The minimal example⌗
tape/examples/non_idempotent_bank/ walks
through:
- Scenario A: crash before dispatch. Outcome: re-dispatch, one wire.
- Scenario B: crash after dispatch but before recording. Outcome:
UNKNOWN →
connector.observe()→ CONFIRMED, no duplicate. - Scenario C: real duplicate (the upstream did process twice). Outcome: observe → DUPLICATE → compensation obligation → reverse.
- Scenario D: counterparty inconclusive. Outcome: STUCK → human gate.
Read it. The kill-test is the proof.
Connectors⌗
The outbox reactor invokes a registered connector under a CAS lease. Built-ins:
HTTPConnector (POST + X-Tape-* headers) and PubSubConnector (publish to a
topic, paired with a downstream subscriber that talks to the upstream). Custom
connectors implement the EffectConnector protocol (dispatch / observe /
compensate) — see the Python connectors reference.
from tape import connectors
from tape.connectors.http import HTTPConnector
connectors.register(HTTPConnector(
name="bank.wire",
endpoint="https://bank.example/wires",
observe_endpoint="https://bank.example/wires/lookup",
compensate_endpoint="https://bank.example/wires/reverse",
))
See also⌗
- Concepts: UNKNOWN — the third outcome, resolved.
- Concepts: compensation & sagas — what
happens after
DUPLICATE. - Write a custom connector — when
HTTPConnectordoesn't fit. - Reactors — the outbox / reconciler / compensation reactors that make this pattern work.
- Cheat sheet — the full incantation on one page.