← All posts

Automatic Idempotency-Key in the SDK

OriginChainDB · May 12, 2026
sdk dx idempotency api-design

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:

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:

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:

10 TS tests + 34 Python tests + 3 Go tests pass.

Honest scope

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.


← All posts Subscribe to RSS →