Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 36 additions & 19 deletions .vitepress/config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,27 +29,44 @@ export default defineConfig({
lastUpdated: true,
nav: [
{ text: "Home", link: "/" },
{ text: "Why Idempotency", link: "/why-idempotency" },
{ text: "Learn", link: "/learn/" },
{ text: "Specs", link: "/specs" }
],
sidebar: [
{
text: "Documentation",
items: [
{ text: "Why Idempotency", link: "/why-idempotency" },
{ text: "Specifications", link: "/specs" }
]
},
{
text: "Projects",
items: [
{
text: "idempot-js",
link: "https://github.com/idempot-dev/idempot-js"
}
]
}
],
sidebar: {
"/learn/": [
{
text: "Learn",
items: [
{ text: "Overview", link: "/learn/" },
{ text: "Why Idempotency", link: "/learn/why" },
{
text: "Duplicated vs Repeated",
link: "/learn/duplicated-vs-repeated"
},
{
text: "Client Key Strategies",
link: "/learn/client-key-strategies"
},
{ text: "Spec Compliance", link: "/learn/spec" }
]
}
],
"/": [
{
text: "Documentation",
items: [{ text: "Specifications", link: "/specs" }]
},
{
text: "Projects",
items: [
{
text: "idempot-js",
link: "https://github.com/idempot-dev/idempot-js"
}
]
}
]
},
socialLinks: [{ icon: "github", link: "https://github.com/idempot-dev" }]
}
});
30 changes: 30 additions & 0 deletions .vitepress/theme/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { h, nextTick, watch } from "vue";
import DefaultTheme from "vitepress/theme";
import { useData } from "vitepress";
import { createMermaidRenderer } from "vitepress-mermaid-renderer";

export default {
extends: DefaultTheme,
Layout: () => {
const { isDark } = useData();

const initMermaid = () => {
const _mermaidRenderer = createMermaidRenderer({
theme: isDark.value ? "dark" : "forest"
});
};

// initial mermaid setup
nextTick(() => initMermaid());

// on theme change, re-render mermaid charts
watch(
() => isDark.value,
() => {
initMermaid();
}
);

return h(DefaultTheme.Layout);
}
};
77 changes: 77 additions & 0 deletions docs/learn/client-key-strategies.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# Client Key Strategies

The client generates idempotency keys. Both strategies create a transfer record first:

## Strategy 1: Random Keys

Create a transfer record, generate a random UUID, store it on the record:

```javascript
// Create transfer record first
const transfer = await db.transfers.create({
supplier_id: supplierId,
invoice_id: invoiceId,
iban,
amount: 10000,
currency: "EUR",
description: "Monthly consulting fee",
internal_reason: `invoice-${supplierId}-${invoiceId}`,
idempotency_key: crypto.randomUUID(),
status: "pending"
});

await fetch("/api/transfers", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": transfer.idempotency_key
},
body: JSON.stringify({
iban: transfer.iban,
amount: transfer.amount,
currency: transfer.currency,
description: transfer.description,
internal_reason: transfer.internal_reason
})
});
```

**Benefit**: No coupling between your database IDs and external API contracts — you can change your ID scheme without affecting idempotency.

## Strategy 2: Database ID as Key

Use the transfer's database-generated ID directly:

```javascript
// Create transfer record first
const transfer = await db.transfers.create({
supplier_id: supplierId,
invoice_id: invoiceId,
iban,
amount: 10000,
currency: "EUR",
description: "Monthly consulting fee",
internal_reason: `invoice-${supplierId}-${invoiceId}`,
status: "pending"
});

// Use transfer ID directly
const idempotencyKey = transfer.id;

await fetch("/api/transfers", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey
},
body: JSON.stringify({
iban: transfer.iban,
amount: transfer.amount,
currency: transfer.currency,
description: transfer.description,
internal_reason: transfer.internal_reason
})
});
```

**Benefit**: Single source of truth — the transfer ID is your idempotency key.
135 changes: 135 additions & 0 deletions docs/learn/duplicated-vs-repeated.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
# Duplicated vs Repeated Operations

Idempotency protects against **duplicated** operations from network retries while allowing **repeated** operations—new requests with the same business parameters.

## The Difference

| Duplicated | Repeated |
| ------------------------------------------------------------------------------------- | ------------------------------------------------------ |
| Same request sent multiple times due to network failures, timeouts, or client retries | New operation that happens to have the same parameters |
| Should return the same response | Should create a new result |
| Protected by idempotency | Allowed by idempotency |

## Example: Monthly Invoice Payments

Your company pays the same vendor each month:

- **January**: Transfer €100 to DE89370400440532013000 for invoice INV-001
- **February**: Transfer €100 to DE89370400440532013000 for invoice INV-002

Same IBAN, same amount, same currency—but two distinct operations.

### Request Model

```javascript
// POST /api/transfers
{
"iban": "DE89370400440532013000",
"amount": 10000, // cents
"currency": "EUR",
"description": "Monthly consulting fee",
"internal_reason": "invoice-550e8400-e29b-41d4-a716-446655440000"
}
```

The `internal_reason` field uniquely identifies this payment in your system.

## How Idempotency Works

### Duplicated Request (Retry)

```http
POST /api/transfers
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
Content-Type: application/json

{
"iban": "DE89370400440532013000",
"amount": 10000,
"currency": "EUR",
"description": "Monthly consulting fee",
"internal_reason": "invoice-550e8400-e29b-41d4-a716-446655440000"
}
```

Network timeout occurs. Client retries:

```http
POST /api/transfers
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
Content-Type: application/json

{
"iban": "DE89370400440532013000",
"amount": 10000,
"currency": "EUR",
"description": "Monthly consulting fee",
"internal_reason": "invoice-550e8400-e29b-41d4-a716-446655440000"
}
```

**Same key, same body** → server returns cached response. No double payment.

### Repeated Operation (New Invoice)

```http
POST /api/transfers
Idempotency-Key: a1b2c3d4-e5f6-7890-abcd-ef1234567890
Content-Type: application/json

{
"iban": "DE89370400440532013000",
"amount": 10000,
"currency": "EUR",
"description": "Monthly consulting fee",
"internal_reason": "invoice-a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
```

**Different key, different body** (different `internal_reason`) → server processes as new transfer.

**Choosing a key strategy?** See [Client Key Strategies](/learn/client-key-strategies) for patterns on generating and managing idempotency keys.

## Server Implementation

**Note:** The following example uses [idempot-js](https://js.idempot.dev) middleware with the Hono framework. For framework-specific implementations, see the [idempot-js documentation](https://js.idempot.dev).

```javascript
import { Hono } from "hono";
import { idempotency } from "idempot-js/hono";
import { RedisIdempotencyStore } from "idempot-js/stores/redis";

const app = new Hono();
const store = new RedisIdempotencyStore({ client: redis });

app.post("/api/transfers", idempotency({ store }), async (c) => {
const { iban, amount, currency, description, internal_reason } =
await c.req.json();

// Process transfer - only executed once per unique idempotency key
const transferId = await processTransfer({
iban,
amount,
currency,
internal_reason
});

return c.json(
{
transferId,
status: "completed",
internal_reason
},
201
);
});
```

## Summary

| Scenario | Idempotency Key | Request Body | Fingerprint | Result |
| --------------------- | --------------- | --------------------------- | ----------- | --------------- |
| Retry of same request | Same | Same | Same | Cached response |
| New invoice payment | Different | Different `internal_reason` | Different | New operation |

The combination of **new key per operation** and **unique `internal_reason` in each request body** ensures retries return cached responses while new operations process normally.
28 changes: 28 additions & 0 deletions docs/learn/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Learn

Idempotency is essential for reliable distributed systems. When networks fail and clients retry requests, idempotency prevents duplicate transactions—no double charges, no duplicate orders.

## Key Concepts

### Why Idempotency Matters

Every API that processes payments, creates orders, or modifies state needs idempotency. Without it, network failures and client retries create duplicate transactions. **[Learn why →](/learn/why)**

### Duplicated vs Repeated Operations

Idempotency protects against duplicates from retries while allowing legitimate repeated operations. Use a different idempotency key for each distinct business operation. **[Learn the difference →](/learn/duplicated-vs-repeated)**

### Client Key Strategies

How should you generate idempotency keys? Learn patterns for managing keys in your client applications. **[See strategies →](/learn/client-key-strategies)**

### IETF Specification

This library implements the IETF draft standard for idempotency keys. Understanding the spec helps you implement idempotency correctly and interoperate with other systems. **[Read the spec compliance guide →](/learn/spec)**

## What You'll Learn

- The problem duplicates create in distributed systems
- How the idempotency-key pattern works
- What the IETF specification requires
- Implementation details for each requirement
60 changes: 60 additions & 0 deletions docs/learn/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# IETF Spec Compliance

The [idempot-js](https://js.idempot.dev) library implements [draft-ietf-httpapi-idempotency-key-header-07](https://datatracker.ietf.org/doc/html/draft-ietf-httpapi-idempotency-key-header-07), the IETF standard for the Idempotency-Key HTTP header.

This document details how the library complies with each requirement in the specification.

## Implemented Requirements

**Note:** This page describes [idempot-js](https://js.idempot.dev) implementation of the IETF specification.

### MUST Requirements (Required)

| Requirement | Section | Implementation |
| ------------------------------------ | ------- | ------------------------------------------------------------------------- |
| Idempotency-Key as String | 2.1 | ✅ Header value extracted as string |
| Unique idempotency keys | 2.2 | ✅ Key stored with request fingerprint to ensure uniqueness |
| Identify idempotency key from header | 2.5.2 | ✅ Parses `Idempotency-Key` header (configurable via `headerName` option) |
| Generate idempotency fingerprint | 2.5.2 | ✅ SHA-256 hash of method + path + body |
| Enforce idempotency | 2.6 | ✅ Returns cached response for duplicate requests |

### SHOULD Requirements (Recommended)

| Requirement | Section | Implementation |
| ----------------------------------------------- | ------- | ------------------------------------------------------------------------------ |
| Use UUID or random identifier | 2.2 | ✅ Library doesn't generate keys (client responsibility), but validates format |
| Publish expiration policy | 2.3 | ✅ Configurable via `ttlMs` option |
| Return 400 if key missing | 2.7 | ✅ Optional via `required: true` option |
| Return 422 if key reused with different payload | 2.7 | ✅ Returns 422 with problem details when fingerprint mismatch detected |
| Return 409 for concurrent requests | 2.7 | ✅ Returns 409 Conflict when original request still processing |

### MAY Requirements (Optional)

| Requirement | Section | Implementation |
| ----------------------- | ------- | --------------------------------------------------------------------------------- |
| Idempotency fingerprint | 2.4 | ✅ SHA-256 hash of method + path + body (configurable via `hashAlgorithm` option) |
| Time-based expiry | 2.3 | ✅ Configurable via `ttlMs` option, defaults to 24 hours |

## Error Responses

The library follows the spec's error handling recommendations:

| Scenario | Status Code | Response |
| --------------------------------------- | ----------- | ----------------------------------------------------------- |
| Missing Idempotency-Key (when required) | 400 | Problem Details JSON with link to documentation |
| Key reused with different payload | 422 | Problem Details JSON with "Idempotency-Key is already used" |
| Concurrent request (still processing) | 409 | Problem Details JSON with "request is outstanding" |

## What's Not Covered

The spec leaves some things to the application layer:

- **Key format**: The spec recommends UUIDs, but the library accepts any string value.
- **Store implementation**: The spec doesn't mandate storage implementation. We provide Redis, PostgreSQL, MySQL, SQLite, and Bun SQL stores.
- **Key generation**: The spec says clients should generate keys. We don't generate keys—clients provide them.

## Compliance Status

✅ **Full compliance** with draft-ietf-httpapi-idempotency-key-header-07

All MUST and SHOULD requirements are implemented. The library gives you the flexibility to choose which optional features to enable based on your API's needs.
Loading
Loading