feat(brokers): give the port an idempotency key so a placement retry is safe - #419
Merged
Conversation
…is safe
Every adapter minted a fresh uuid4 `client_order_id` per place_order CALL,
so no retry was ever deduplicated. A caller retrying after a timeout --
exactly when the first request may already have reached the venue -- placed
a SECOND live order, because the retry carried a different id and the venue
had nothing to match it against. Robinhood and Alpaca documented the hazard;
Coinbase's docstring asserted the opposite ("a fresh client_order_id per
call gives Coinbase idempotency"), which had it backwards -- a per-attempt
id is precisely what WITHHOLDS idempotency.
The port could not express the difference, which is why this was an adapter
comment rather than a fix: `place_order(spec)` cannot tell a retry of one
intent from two orders a strategy genuinely meant to place. So the port now
carries it:
place_order(spec, *, idempotency_key: str | None = None)
`None` mints per ATTEMPT and is the unchanged default, because the opposite
default is not safer -- an id derived from the order would collapse two
deliberate orders into one, and a position at half the intended size is as
wrong as one at twice it. A key resolves every attempt under it to one
venue-facing id.
`resolve_client_order_id` in `keel_broker_api.port` owns the derivation, and
hashes the key to a uuid5 rather than passing it through. That is not
cosmetic: the venues do not agree on what a client order id may be --
Robinhood's is a UUID, Alpaca's a string of up to 128 characters -- so
hashing lands on the intersection and a caller can use whatever natural key
it has without knowing where the order will be routed. One derivation in one
place; an adapter inventing its own rule would make the same key mean
different orders at different venues.
uuid5 is deterministic across processes, which is the case that matters: a
retry issued by a NEW process, which is what a crash produces, derives the
same id as the attempt that crashed. An in-memory table of "ids I already
sent" could not cover that.
The namespace is pinned by a test that writes the expected UUID out rather
than recomputing it, because recomputing would pass just as happily if the
namespace changed -- and a changed namespace silently makes every
previously-derived id unreachable.
Threaded through all four adapters. The fake ACCEPTS the parameter and
deliberately ignores it: it has no client_order_id to carry it into, and a
dedup table there would make the stand-in behave better than the venues it
stands in for.
The conformance suite asserts ACCEPTANCE and that a repeat under one key
still reaches the adapter -- not deduplication, which is the venue's
behaviour and cannot honestly be claimed by a suite running on canned
transports.
This does not add retries anywhere. It makes one possible.
Closes #409.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Aug 19, 2026
Merged
eaitbrahim
added a commit
that referenced
this pull request
Aug 20, 2026
Minor, not patch. Three things since v0.9.3 change what an implementor or a deployment can rely on: * A NEW DISTRIBUTION. `keel-broker-alpaca` (#382, #384) plus the paper-equities profile that selects it (#386), so a deployment can now be US equities via the broker port rather than crypto only. * THE PORT CONTRACT MOVED TWICE. `market_clock`/`market_schedule` made venues session-aware (#385), and `place_order` gained `idempotency_key` (#419). Both carry defaults so no CALLER breaks, but a third-party adapter that does not accept them is no longer a `Broker` -- the conformance suite now says so. That is exactly the kind of change a patch bump must not hide. * THE OPERATOR CONSOLE. The TUI became keel's console across #399-#408, and `keel update` (#415/#417) makes a deployment self-updating. Every pinned sibling moves with it. The four production distributions are required `==` at this exact version (`RELEASING.md`, "Release assets"), so a bump that missed one would install a mixed set -- the `keel-trader 0.5.7` against `keel-core 0.5.5` failure `keel versions` exists to catch, and which `~/keel` actually ran across two releases. Also in this window, on the Robinhood adapter: the best_bid_ask fixture corrected against the live venue (#414), a credential guard that catches the error it only claimed to (#416), pre-flight sizing reported on the preview (#418), transport backoff (#420), and the fenced one-order probe (#421). Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #409.
The defect
Every adapter minted a fresh
uuid4client_order_idperplace_ordercall, so no retry was ever deduplicated. A caller retrying after a timeout — exactly when the first request may already have reached the venue — placed a second live order, because the retry carried a different id and the venue had nothing to match it against.Robinhood and Alpaca documented the hazard. Coinbase's docstring asserted the opposite:
That had it backwards. A per-attempt id is precisely what withholds idempotency — it leaves the venue nothing to deduplicate on.
Why it stayed a comment
The port could not express the difference.
place_order(spec)cannot tell a retry of one intent from two orders a strategy genuinely meant to place. So the port now carries it:Nonemints per attempt — the unchanged default. The opposite default is not safer: an id derived from the order would collapse two deliberate orders into one, and a position at half the intended size is as wrong as one at twice it.The derivation, and why it hashes
keel_broker_api.port.resolve_client_order_idowns it, and turns the key into auuid5rather than passing it through:The venues do not agree on what a client order id may be — Robinhood's is a UUID, Alpaca's a string of up to 128 characters. Hashing lands on the intersection, so a caller uses whatever natural key it has (a cycle id, a position id, a leg name) without knowing which venue the order will be routed to. One derivation in one place: an adapter inventing its own rule would make the same key mean different orders at different venues.
uuid5is deterministic across processes, which is the case that matters. A retry issued by a new process — what a crash produces — derives the same id as the attempt that crashed. An in-memory table of "ids I already sent" cannot cover that.The namespace is pinned by a test that writes the expected UUID out rather than recomputing it from the implementation. Recomputing would pass just as happily if the namespace changed, and a changed namespace silently makes every previously-derived id unreachable — which is the deduplication this mechanism exists to provide.
Threading
to_order_body(client_order_id=...)create_order(client_order_id=...), backwards docstring correctedto_order_body(client_order_id=...)client_order_idto carry it into, and a dedup table there would make the stand-in behave better than the venues it stands in forConformance
Two new contract tests. They assert acceptance, and that a repeat under one key still reaches the adapter — not deduplication, which is the venue's behaviour and cannot honestly be claimed by a suite running on canned transports. The second one also pins that an adapter must not remember keys locally and refuse the repeat: deduplication belongs to the venue, the only party that knows whether the first attempt actually landed.
Scope
This does not add retries anywhere. It makes one possible. #411's
create_orderbackoff was blocked on exactly this — retrying a POST without an idempotency key doubles live orders — and is now unblocked.Full suite: 3684 passed, 3 skipped.
ruff checkandmypy(189 files) clean.🤖 Generated with Claude Code