> ## Content Index
> Fetch the complete content index at: https://blogs.originchaindb.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# Automatic Idempotency-Key in the SDK
- URL: https://blogs.originchaindb.com/auto-idempotency-key-sdk/
- Published: 2026-05-12T16:00:00.000Z
- Updated: 2026-11-07T16:42:05.000Z
- Description: An implementation note on automatic Idempotency-Key headers, bounded deduplication caches and retry behaviour. Check the SDK version and endpoint contract.
- Author: OriginChainDB Team
- Tags: Engineering, sdk, dx, idempotency, api-design, #migrated-website, #byline-originchaindb

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) → response` in 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` \- `insertCurl` no 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 Python `rows.put` example no longer passes `idempotency_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_batch` per-chunk keys share a base UUID - a 3-chunk batch produces `{base}-c0`, `{base}-c1`, `{base}-c2` with 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 `curl` user 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](https://originchaindb.com/blogs/idempotent-tool-calls?ref=blogs.originchaindb.com) \- the agent-loop pattern this header exists for.
- [Backpressure: 429 + Retry-After](https://originchaindb.com/blogs/backpressure-429?ref=blogs.originchaindb.com) \- when the engine asks you to retry, and how to do it politely.