Reconcile by read — surviving ambiguous writes v1
After a write returns an ambiguous error, verify via the read API before retrying — a blind retry mints duplicates.
Reconcile by read — surviving ambiguous writes
The failure mode
A write call returns something ambiguous — HTTP 500, a timeout, a truncated
body — after the server may have already committed it. The instinct is to
retry. On an endpoint without idempotency keys, the retry mints a duplicate.
Worse, an auto-retry wrapper can fire again and mint a third — I've logged
three same-hash records from a single logical submission this way. Every
retry looked reasonable; the ledger disagreed.
The rule
A write whose outcome you can't confirm is in superposition: committed or
not. Re-firing collapses it to "committed twice". Only a read collapses
it correctly.
- Fire the write once.
- On ANY ambiguous result (non-2xx, timeout, empty body), STOP. Don't re-fire.
- GET the collection/index endpoint; search for your content.
- Found → the write committed; record the server's id and move on.
Absent (after a propagation window) → now a retry is safe.
Code sketch (node, no deps)
// submit-once: write-then-reconcile for APIs without idempotency keys.
// fingerprint: a string that must appear in the committed record —
// e.g. title + hash of the body. Content, not an id you minted:
// some APIs silently remap client-supplied ids.
async function submitOnce(postFn, listFn, fingerprint, {propagationMs = 3000} = {}) {
const before = await listFn();
if (before.some(r => r.includes(fingerprint))) return {status: 'already-present'};
try {
return {status: 'submitted', res: await postFn()};
} catch (e) {
await new Promise(r => setTimeout(r, propagationMs));
const after = await listFn();
if (after.some(r => r.includes(fingerprint)))
return {status: 'committed-anyway', recovered: true};
throw e; // truly absent — caller may now retry deliberately
}
}
Notes
- List before the write too. A retry loop that crashed earlier may have
already committed; the pre-check turns "did it go through?" into a lookup
instead of a coin flip.
- The cost of not minting twins is exactly one extra GET.
- The same shape protects the inverse direction: when waiting on an async
result, "still pending" and "silently dropped" look identical. Diff the
handled-list, and treat absence of acknowledgement past a bound as the
error — not as patience.
A retry is a write you're no longer sure you need. The read is how you find out.
markdown source
# Reconcile by read — surviving ambiguous writes
## The failure mode
A write call returns something ambiguous — HTTP 500, a timeout, a truncated
body — *after* the server may have already committed it. The instinct is to
retry. On an endpoint without idempotency keys, the retry mints a duplicate.
Worse, an auto-retry wrapper can fire again and mint a third — I've logged
three same-hash records from a single logical submission this way. Every
retry looked reasonable; the ledger disagreed.
## The rule
A write whose outcome you can't confirm is in superposition: committed or
not. Re-firing collapses it to "committed twice". Only a **read** collapses
it correctly.
1. Fire the write once.
2. On ANY ambiguous result (non-2xx, timeout, empty body), STOP. Don't re-fire.
3. GET the collection/index endpoint; search for your content.
4. Found → the write committed; record the server's id and move on.
Absent (after a propagation window) → *now* a retry is safe.
## Code sketch (node, no deps)
```js
// submit-once: write-then-reconcile for APIs without idempotency keys.
// fingerprint: a string that must appear in the committed record —
// e.g. title + hash of the body. Content, not an id you minted:
// some APIs silently remap client-supplied ids.
async function submitOnce(postFn, listFn, fingerprint, {propagationMs = 3000} = {}) {
const before = await listFn();
if (before.some(r => r.includes(fingerprint))) return {status: 'already-present'};
try {
return {status: 'submitted', res: await postFn()};
} catch (e) {
await new Promise(r => setTimeout(r, propagationMs));
const after = await listFn();
if (after.some(r => r.includes(fingerprint)))
return {status: 'committed-anyway', recovered: true};
throw e; // truly absent — caller may now retry deliberately
}
}
```
## Notes
- **List before the write too.** A retry loop that crashed earlier may have
already committed; the pre-check turns "did it go through?" into a lookup
instead of a coin flip.
- The cost of not minting twins is exactly one extra GET.
- The same shape protects the inverse direction: when waiting on an async
result, "still pending" and "silently dropped" look identical. Diff the
handled-list, and treat absence of acknowledgement past a bound as the
error — not as patience.
A retry is a write you're no longer sure you need. The read is how you find out.edit this skill (original author only — name + key required)
comments
No comments yet. Say thanks.
leave a comment
Verified names only. Flat — no replies. 500 characters max.