feat(api): accept a client-supplied idempotency key on the eight non-convergent writes - #241
feat(api): accept a client-supplied idempotency key on the eight non-convergent writes#241LKSNDRTMLKV wants to merge 2 commits into
Conversation
…convergent writes
Up to standards ✅🟢 Issues
|
| Metric | Results |
|---|---|
| Complexity | 124 |
| Duplication | 18 |
NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.
|
Pushed
It only surfaced late because Scoped to |
Closes #145.
The premise in #145 needed correcting before it could be built against
The issue motivates itself with "a client that retries a
POSTafter atimeout". That client does not exist. The bulk importer retries only on
429— a rejection, so no duplicate is possible — and the CLI does not retrywrites at all.
POST /dpp, the issue's own example, has no retrying caller.Its actual argument survives intact, and is why this is worth doing now:
retrofitting idempotency once clients exist is either a breaking change to
their retry behaviour or a silent one. This is therefore API-surface design
with a small enabling implementation, not a defect fix.
One real defect is hiding under the same heading — the resolver's scan flush
folds a failed window back in and re-sends it against an additive ingest, so a
read timeout on a committed request double-counts. That is a separate PR, and
it will use this mechanism rather than a private one.
Scope: eight routes, chosen by effect not verb
The test is does a replay create a second thing, or spend something that
cannot be un-spent.
POST /dpp, evidence generation, plugin install, and thecreates behind api-keys, webhooks, facilities, operator-identifiers and bulk
import. Facilities and operator-identifiers are on the list because
retire-not-delete (0013/0014 revoked those DELETE grants) makes a duplicate
permanent.
PUTand every lifecycle transition are excluded — they converge. The honestqualification is in the code: they converge in state, not in effects; a
second publish appends an audit row and re-signs. The seal outbox already
handles its half correctly (0028 keys on
(passport_id, payload_hash)).Eight, not the ten the design note first counted.
POST /credentialsandPOST /unsold-goodsdo not exist onmain— they are on an unmerged branch. Apolicy entry naming a route nobody serves is worse than a missing one, because
policy_forsimply never matches and the table reads as protection that is notthere.
every_keyed_route_is_a_route_the_node_servesnow fails the build onexactly that, and I verified it goes red before trusting it.
A key on an unkeyed route is a
400, not a no-op. Silently accepting itwould advertise a protection that is not being recorded.
The four questions
(principal, method, path, key),24h, swept hourly.
pathis the matched route template, so a callercannot mint unbounded rows by varying a path parameter. This makes it the
fourth entry in
ops/pg/README.md's DELETE set;grants-checkfaileduntil that list was edited, which is the gate working.
canonicalised JSON. Canonicalising would invent a normalisation this API
does not otherwise have.
422with its own problemtype, built fromProblemdirectly:api_errorderives the URI fromstatus.canonical_reason(), so routing it through there would have emittedthe generic
unprocessable-entityand made it indistinguishable from everyother validation failure. A test asserts all seven refusals have distinct
type URIs.
Idempotency-Replayed: trueso a client can tell a working retry from a duplicating one. One carve-out:
POST /api-keysandPOST /webhooksreturn a secret once, so the secret isnever stored and the replay carries
"secretAlreadyDelivered": truein itsplace. A pointer design would have diverged for every route and could not
have worked for these two at all. The alternative was parking a live
credential in a table for a day.
The fifth question, which decides the implementation
A middleware cannot commit its row in the handler's transaction — the
repository ports beneath are per-operation. Hence
in_flight→completed,with a 60s lease so a crash cannot wedge a key. Three rules: an unexpired claim
answers
409 + Retry-After; a5xxreleases the claim (the client must be ableto retry); a
4xxis recorded (deterministic, cheap to replay).The residual hole — handler committed, record did not — is unavoidable without
threading the key through nine handlers' transactions, which has a worse
forgetting-failure mode. Its blast radius is today's behaviour, so this is never
a regression. It is a
warn!, a CHANGELOG paragraph, and a comment at the exactline, rather than something to discover.
An unavailable store refuses the write rather than running it: executing
with no record is precisely the outcome the caller asked to be protected from.
Placement
Port and middleware in
dpp-common. Two crates mount keyed routes and neitherdepends on the other;
dpp-commonis the only one both reach that hasaxum,and nothing here carries a domain type. Putting the port in
dpp-typeswith theothers would have meant
dpp-common → dpp-types, draggingdpp-typesanddpp-rulesintodpp-resolver's build for nothing.dpp-dalgains adpp-commonedge instead, which keeps every Postgres repo in the DAL.The two services resolve the principal differently, and had to: the vault reads
the
AuthContextits middleware inserted; the integrator authenticates insideits handlers, so it derives a truncated SHA-256 of the bearer token — never the
token, since the value becomes a primary-key column.
Verification
matched_path_inside_a_nested_route_layer_is_the_full_templatepins theassumption the entire policy table rests on. Had axum reported only the inner
segment, every lookup would miss and every keyed route would silently stop
being keyed, with nothing else noticing.
crates/dpp-common/tests/idempotency_flow.rs— 7 end-to-end tests through areal router: replay, handler-runs-once, mismatch, unkeyed-route refusal,
no-key passthrough, fail-closed, in-flight, per-principal scoping.
crates/dpp-dal/tests/pg_idempotency.rs— 8 tests against real Postgres forwhat only SQL can answer, including 8 concurrent claimants where exactly one
may win, and the
CHECKthat refuses a completed row with no response.fmt-check,clippy -D warnings(workspace and the feature-gatedintegration suites), 87 test binaries,
openapi-check(rebundled, Redoclyclean), and all nine script gates.
Not in this PR
The scan-telemetry double-count, and
POST /credentials/POST /unsold-goodswhen their branch lands. Both are noted in the code where the next person will
look.