Summary
Retries are the default behavior of production HTTP clients (timeouts, connection resets, 5xx). For most Omnigraph routes that's safe — /load --mode merge upserts by slug, /query is a read. But /branches/merge is not idempotent under concurrency: a retry that races the still-executing first request double-applies the merge and produces duplicate rows for the same @key value on the target (observed in production on v0.8.1 — details and impact in #415).
Today the only safe client policy is "never retry a merge," which has to be discovered the hard way and re-implemented in every client.
Ask
Either (or both):
Idempotency-Key request header on non-idempotent writes (/branches/merge; arguably /mutate for callers whose mutations aren't naturally idempotent). Server stores the key with the operation's outcome for a bounded window; a replay with the same key returns the stored outcome instead of re-executing. This is the established pattern (Stripe-style) and makes client retry policy uniformly safe.
- Natural at-most-once for merge: key the merge on
(source branch, source head version, target). If that source head has already been merged into the target — or a merge job for it is currently in flight — return the prior/attached outcome (already_up_to_date) instead of computing and applying a second diff. Git semantics already imply this; it just needs to hold under concurrent requests, not only sequential ones.
The second option needs no client changes at all and closes the double-apply hole even for clients that retry naively.
Related: #415 (async merge job API — the two compose: job dedup by key), #384 (slow merges are what make client timeouts + retries common).
Summary
Retries are the default behavior of production HTTP clients (timeouts, connection resets, 5xx). For most Omnigraph routes that's safe —
/load --mode mergeupserts by slug,/queryis a read. But/branches/mergeis not idempotent under concurrency: a retry that races the still-executing first request double-applies the merge and produces duplicate rows for the same@keyvalue on the target (observed in production on v0.8.1 — details and impact in #415).Today the only safe client policy is "never retry a merge," which has to be discovered the hard way and re-implemented in every client.
Ask
Either (or both):
Idempotency-Keyrequest header on non-idempotent writes (/branches/merge; arguably/mutatefor callers whose mutations aren't naturally idempotent). Server stores the key with the operation's outcome for a bounded window; a replay with the same key returns the stored outcome instead of re-executing. This is the established pattern (Stripe-style) and makes client retry policy uniformly safe.(source branch, source head version, target). If that source head has already been merged into the target — or a merge job for it is currently in flight — return the prior/attached outcome (already_up_to_date) instead of computing and applying a second diff. Git semantics already imply this; it just needs to hold under concurrent requests, not only sequential ones.The second option needs no client changes at all and closes the double-apply hole even for clients that retry naively.
Related: #415 (async merge job API — the two compose: job dedup by key), #384 (slow merges are what make client timeouts + retries common).