Automatic Idempotency-Key in the SDK
Every mutating call from the OriginChainDB TypeScript, Python and Go SDKs now attaches an Idempotency-Key UUIDv4 automatically, so a retried write no longer duplicates a row. You pass a key yourself only when you need the same one across processes. The engine caches each key’s response for 24 hours and evicts LRU at 10,000 entries, which is why a fresh key per call cannot grow the cache without bound.
A customer pasted a curl from our quickstart into Postman and noticed this:
-H "Idempotency-Key: 7c4f...e1" \
His question: “do we require an idempotency key on every write? why is this in the docs?”
The honest answer was “the server doesn’t require it; we recommend it; the example hardcoded a placeholder that’s misleading because it makes the header look like a magic constant tied to that endpoint.” A worse answer would have been “yes, you need to mint a fresh UUID before every write.” Neither is the answer he should have to think about.
The interesting part isn’t the SDK change - it’s the prerequisite we had to check before flipping the default.
The header, briefly
OriginChainDB’s engine supports the same Idempotency-Key header
that DynamoDB / Stripe / OpenAI use:
- Client picks a string per logical operation (typically a UUID).
- Engine records
(client_id, idempotency_key) → responsein an in-memory cache with a 24-hour TTL. - Retries of the same key replay the cached response instead of re-applying the write. A 502-and-retry no longer means a duplicate row.
It’s the standard “at-least-once delivery with exactly-once effect”
pattern. The engine implements it; the docs document it; the SDKs
provided an idempotency_key parameter that defaulted to None.
That None default was the bug. The user had to know about
idempotency, decide whether to use it, generate a UUID, pass it in.
For an SDK aimed at customers building AI features - most of whom
have opinions about agents and embeddings, not about HTTP retry
semantics - that’s the wrong layering.
The fix is one-line per SDK: when the caller passes None, generate
a UUIDv4 internally and put it on the header.
The prerequisite
Before flipping the default, there’s a question that has to be answered correctly. If you skip this question and ship the new default, you might OOM the engine.
The engine’s idempotency cache holds (client_id, key) → response
for 24 hours. Today, very few callers send the header - most writes
have no key, and the cache is sparse. When every SDK call starts
sending a fresh UUID, the cache fills up. A tenant writing 1000
rows/second × 86,400 seconds/day = 86.4 million entries in the cache
at steady state. At a few hundred bytes each, that’s ~25 GB. We have
2 GB RAM on the smallest tier.
The question is: is the cache bounded by anything other than the 24-hour TTL?
We had no idea off-hand. We knew the cache existed; we didn’t know the cap. So before any SDK change, we read the cache implementation in the engine:
pub struct IdempotencyCache {
inner: Mutex<Inner>,
ttl: Duration,
cap: usize,
}
struct Inner {
map: HashMap<CacheKey, (Value, Instant)>,
order: VecDeque<CacheKey>, // LRU order
}
impl IdempotencyCache {
pub fn put(&self, client_id: String, key: String, value: Value) {
let mut g = self.inner.lock.unwrap;
let ck = CacheKey { client_id, key };
if let Some((_, ts)) = g.map.get(&ck) {
// refresh existing - update timestamp + move to back
...
} else {
// evict oldest if at capacity
while g.map.len >= self.cap {
if let Some(oldest) = g.order.pop_front {
g.map.remove(&oldest);
}
}
g.map.insert(ck.clone, (value, Instant::now));
g.order.push_back(ck);
}
}
...
}
LRU-bounded at cap (default 10,000 entries) in addition to the
24-hour TTL prune. The cache cannot grow past 10,000 entries.
That’s the answer we needed. The “every SDK call sends a fresh UUID could OOM the engine” concern turns out to be moot. The cache will LRU-evict the oldest entries when full. A pathological tenant doing 1 M writes/second would have its cache turn over in 10 ms; idempotency becomes effectively useless at that point, but the engine doesn’t fall over.
The SDK changes
With safety established, the actual changes are minimal:
TypeScript (sdk/typescript/src/client.ts)
A newIdempotencyKey helper that prefers crypto.randomUUID and
falls back to crypto.getRandomValues for older browser targets.
The _request plumbing auto-stamps it on mutating methods only
(POST / PUT / PATCH / DELETE):
const MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
const method = (init.method ?? "GET").toUpperCase;
if (MUTATING_METHODS.has(method) && !headers["idempotency-key"]) {
headers["idempotency-key"] = newIdempotencyKey;
}
Caller-provided headers win. If you pass init.headers["idempotency-key"] = "my-stable-key", that’s used; the auto-gen only kicks in when
absent.
Python (sdk/python/originchain/client.py and async_client.py)
Same shape. _request auto-stamps. The interesting one is
put_batch: previously, when the caller passed no key, no header
went out for any chunk. With auto-gen, naive code would generate a
fresh UUID per chunk, breaking partial-retry dedup: if the third
chunk of a 100-chunk batch fails and the caller retries the whole
call, the new run gets new per-chunk UUIDs and the engine can’t
detect overlap.
Fix: mint ONE base UUID at the top of put_batch, then derive
per-chunk keys deterministically:
def put_batch(self, schema, rows, *, idempotency_key=None, chunk=1000):
base_idem = idempotency_key or _new_idempotency_key
...
for i, r in enumerate(rows):
chunk_buf.append(r)
if len(chunk_buf) >= chunk:
total += self._send_batch(schema, chunk_buf,..., base_idem, i // chunk)
...
def _send_batch(self, schema, rows,..., base_idem, chunk_no):
headers = {"Idempotency-Key": f"{base_idem}-c{chunk_no}"}
...
A retry of the SAME logical put_batch call (same process, same
batch object) gets the same base UUID - chunks dedup. Callers who
want cross-process retry of the same logical action pass idempotency_key ="abc" explicitly; chunks become "abc-c0", "abc-c1", dedupable
forever.
Go (sdk/go/client.go)
Stdlib crypto/rand + encoding/hex, zero new dependencies (the
Go SDK’s go.sum was empty and we wanted to keep it that way):
func newIdempotencyKey string {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return ""
}
b[6] = (b[6] & 0x0f) | 0x40 // version 4
b[8] = (b[8] & 0x3f) | 0x80 // variant 1
out := make([]byte, 36)
hex.Encode(out[0:8], b[0:4])
out[8] = '-'
hex.Encode(out[9:13], b[4:6])
out[13] = '-'
hex.Encode(out[14:18], b[6:8])
out[18] = '-'
hex.Encode(out[19:23], b[8:10])
out[23] = '-'
hex.Encode(out[24:36], b[10:16])
return string(out)
}
func isMutatingMethod(method string) bool {
switch strings.ToUpper(method) {
case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
return true
}
return false
}
The shared request function auto-attaches the header for mutating
methods only. GETs never consume an idempotency cache slot.
Docs cleanup
The hardcoded Idempotency-Key: 7c4f...e1 in the quickstart was
worse than useless - it implied the header was required and that
the value was constant. Both wrong. Three docs pages got the
header dropped from the minimal curl examples:
quickstart.astro-insertCurlno longer carries the header. The prose now reads: “Retries are safe by default: the SDKs auto-attach an Idempotency-Key on every mutating call. Set your own only when hitting raw HTTP and you need cross-process retry semantics.”insert.astro- same treatment on the single-row and batch curl examples.sdk.astro- the Pythonrows.putexample no longer passesidempotency_key=str(uuid.uuid4)(the SDK does it).
The API reference page api.astro keeps the header in the optional-
headers table - that’s the right place for it. We didn’t delete the
concept, just stopped requiring users to think about it.
Tests
Across the three SDKs, 15 new test assertions:
- Auto-generated key is canonical UUIDv4 (hex32 or hyphenated 36 depending on language).
- Caller-supplied key wins (override still works).
- GET never sends Idempotency-Key.
- Python
put_batchper-chunk keys share a base UUID - a 3-chunk batch produces{base}-c0,{base}-c1,{base}-c2with one common prefix.
10 TS tests + 34 Python tests + 3 Go tests pass.
Honest scope
- The header is still optional on the engine. This change is
pure SDK-side. A raw
curluser who doesn’t send the header gets the same behaviour as before (no dedup); the engine doesn’t require it. We just made the SDK helpful by default. - The cache bound is process-local. Each tenant has one cache today. With multi-writer (on the roadmap), the cache would need to be replicated or scoped per writer. Cross-replica idempotency is a v2 problem; we’ll keep this property in mind.
- The account/management API doesn’t dedupe. The SDK auto-stamps on calls to both the engine and the management API, but management endpoints (signup, signin, instance-create) don’t consume the header today. The header arrives, is ignored, future consumption is a one-line opt-in if we ever want it.
What this is
A small DX win, but the kind that compounds. Customers were typing UUIDs to defend against a failure mode they didn’t have to think about. The right shape is “do it for them automatically; let them override when they need to.” That’s the default DynamoDB / Stripe / OpenAI established for transactional APIs; we should match it.
If we’d shipped the SDK change first and discovered the cache was unbounded only in production, we would have OOM’d a fleet of tenants under a write-heavy workload.
Try it
Update the SDK:
npm install --save @originchain/sdk@latest
pip install --upgrade originchain
go get -u github.com/originchain-ai/originchain-go
Make a write. Don’t pass an idempotency key. Retry on failure. It deduplicates. You didn’t have to think about it.
One bearer. One atomic write. One header you no longer have to generate.
What to read next
- Idempotent tool calls - the agent-loop pattern this header exists for.
- Backpressure: 429 + Retry-After - when the engine asks you to retry, and how to do it politely.