Idempotency (idempotency)
The idempotency component lets developers add idempotency support to their services, letting their API users safely retry requests while avoiding accidentally performing the same operation twice.
How idempotency works
Idempotency implements a kind of locking mechanism to prevent the same requests from being performed twice when retried. To implement that, we need to define what constitutes two requests being the same. The recommended pattern is to use the identity of the caller (to prevent different callers from conflicting) and a unique user-provided per-request key that’s retained on retries (to identify a specific set of requests). It’s also recommended to include the URI of the request in order to prevent common user-errors of reusing the same idempotency key across multiple requests.
For example, you could have your users (or SDKs) pass an Idempotency-key header with each request (like done by Svix ), which will be used as the user-provided key, and you can use the auth token ID, or user ID as the identity of the caller.
For example:
hasher = Sha256()
hasher.update(auth_token_id)
hasher.update(":")
hasher.update(user_provided_idempotency_key)
hasher.update(":")
hasher.update(request_uri)
// Get the base64 string of generated key
let idem_key = hasher.finalize().to_base64()Idempotency request flow
Each idempotency flow starts with idempotency.start, which indicates to Diom that a request has been initiated.
This request returns one of three values:
Started: indicating that the idempotency lock has been acquired and we should continue with processing the request.Completed: indicating that a request with the same key has previously completed and we shouldn’t process it again.Locked: a request with the same idempotency key is currently in progress and we should hold the request and try again in a moment.
Under normal operations your requests would call idempotency.start, get back Started to indicate the request can start, and once done call idempotency.complete to indicate the request has processed. Any subsequent requests with the same key would immediately get Completed which would let you immediately return from the API indicating that the request has already been processed.
There is one scenario that requires more care, which is the Locked state. This indicates that there’s a concurrent request with the same idempotency key that’s still being processed. Because we don’t yet know whether the concurrent request would finish successfully or not, we can’t respond with an indication that the request is already being processed. Because if the concurrent request will fail, we want to make sure it’s retried. Implementations usually go with one of two options here: (1) calling idempotency.start in a loop inside the API to see whether the other request would end, or (2) immediately return with an error code indicating that the request should be retried after a timeout.
Please note that in HTTP APIs idempotency keys are generally only used with POST requests, as GET and DELETE are considered idempotent. Whether to use idempotency with PUT and PATCH is usually implementation specific.
Starting a request (idempotency.start)
The idempotency.start request is used to start a request, and depending on its return value, we process the request in one of three ways:
import { Diom } from "diom";
import { Temporal } from "temporal-polyfill-lite";
const client = new Diom("AUTH_TOKEN");
const idemKey = hashOfComponents();
const result = await client.idempotency.start(idemKey, {
lockPeriod: Temporal.Duration.from({ seconds: 15 }),
});
if (result.status === "started") {
// We hold the lock — do the actual work now.
const response = await doSomeRealWork();
// Store the result and release the lock.
await client.idempotency.complete(idemKey, {
response: new TextEncoder().encode(response),
ttl: Temporal.Duration.from({ hours: 12 }),
});
return response;
} else if (result.status === "locked") {
// Another instance is processing the same key right now.
return 423; // HTTP Locked
} else { // completed
// A previous call already finished. Return the cached response.
return new TextDecoder().decode(result.data.response);
}Marking a request successful (idempotency.complete)
As demonstrated in the previous example, once a request has been successfully processed we should mark it as successful by calling the idempotency.complete call.
Copying the example here for completeness:
// How long to store a successful request for
await client.idempotency.complete(idemKey, {
response: responseBytes,
context: { status_code: String(statusCode) },
ttl: Temporal.Duration.from({ hours: 12 }),
});Context allows you to store additional key-value pairs of information attached to the request. A common pattern is to store the status_code and optionally headers of a request.
You can use multiple context keys, or just serialize a full JSON object into one key.
Aborting an idempotency request (idempotency.abort)
If you’ve experienced a failure while processing a request, you can let Diom know that the request failed, which will immediately release the idempotency lock and let other concurrent requests or retries try as well.
Here is how to abort a request:
await client.idempotency.abort(idemKey, {});Examples
Here are a few examples of common use-cases when implementing idempotency in an HTTP API.
Implementing idempotency in a middleware
A common pattern is to implement idempotency as a middleware instead of manually implementing it in each route.
Here is how such a middleware implementation may look like. The function next() is responsible to call the next middleware in the chain:
fn middleware() {
match client.idempotency()
.start(idem_key.clone(), IdempotencyStartIn::new(lock_period))
.await?
{
IdempotencyStartOut::Started => {
// Call the next middleware / request handler
let response = next();
if response.error {
// Abort the idempotency request
client.idempotency().abort(idem_key.clone(), IdempotencyAbortIn::new())
.await?;
return Response(status=500);
}
// Store the result and release the lock.
let success_retention_period = Duration::from_hours(12);
client.idempotency().complete(
idem_key.clone(),
IdempotencyCompleteIn::new(result, success_retention_period),
)
.await?;
return Response(status=response.status, body=response.body);
}
IdempotencyStartOut::Locked => {
// Return an error that the request is currently locked
return Response(status=423_LOCKED);
}
IdempotencyStartOut::Completed(cached) => {
// A previous call already finished. Return the cached response.
return Response(body=cached);
}
}
}See the Middleware examples section below for examples for various frameworks.
Emulating success vs. returning a 409 conflict
There are two main ways to handle idempotency: emulating a successful request on retries vs. returning a 409 conflict.
When emulating a successful request, every retry with the same idempotency returns exactly the same response (body, status code, and potentially headers) as the original request. So each caller thinks that they are the only one that made a call, and it was successful. The advantage of this approach is that it makes client code (users of your API) very simple, as they don’t need to think about idempotency, they just handle the retries as successful requests. This is what companies like Stripe and Svix do.
The alternative is to return a 409 (Conflict) HTTP status code, indicating a conflict has occurred when a second idempotency request follows a successful one. The advantages of this approach are lower resource consumption (you don’t need to store responses), and also giving your API users an indication that a request was previously successful which may be useful in some scenarios.
Storing headers, response status codes, etc.
For simplicity, the examples above only showed how to store the response body for successful idempotency requests. But you can also use the response field in idempotency.complete to store the response headers and status code of the original request, making sure you return exactly the same response in case your API utilizes those.
Middleware examples
Below are example middleware implementations for common web frameworks. Each example reads the Idempotency-Key header, drives the idempotency state machine, and stores the response body so retries get an identical reply.
Rust (Axum)
use std::time::Duration;
use axum::{
body::Body,
extract::State,
http::{Request, StatusCode},
middleware::Next,
response::Response,
};
use diom_client::{
DiomClient,
models::{IdempotencyAbortIn, IdempotencyCompleteIn, IdempotencyStartIn, IdempotencyStartOut},
};
pub async fn idempotency_middleware(
State(diom): State<DiomClient>,
req: Request<Body>,
next: Next,
) -> Result<Response, StatusCode> {
// Skip requests without an idempotency key
let Some(idem_key) = req
.headers()
.get("idempotency-key")
.and_then(|v| v.to_str().ok())
.map(str::to_owned)
else {
return Ok(next.run(req).await);
};
let lock_period = Duration::from_secs(15);
match diom
.idempotency()
.start(idem_key.clone(), IdempotencyStartIn::new(lock_period))
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
{
IdempotencyStartOut::Started => {
let response = next.run(req).await;
if !response.status().is_success() {
// Release the lock so the caller can retry
let _ = diom
.idempotency()
.abort(idem_key, IdempotencyAbortIn::new())
.await;
return Ok(response);
}
// Buffer the body so we can cache it and still return it
let (parts, body) = response.into_parts();
let body_bytes = axum::body::to_bytes(body, usize::MAX)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let mut context = std::collections::HashMap::new();
context.insert("status_code".to_owned(), parts.status.as_u16().to_string());
// You can also store response headers here as additional entries, e.g.:
// context.insert("x-request-id".to_owned(), header_value);
let retention = Duration::from_secs(12 * 3600);
let _ = diom
.idempotency()
.complete(
idem_key,
IdempotencyCompleteIn::new(body_bytes.to_vec(), retention)
.with_context(Some(context)),
)
.await;
Ok(Response::from_parts(parts, Body::from(body_bytes)))
}
IdempotencyStartOut::Locked => {
// A concurrent request with the same key is in flight
Err(StatusCode::LOCKED)
}
IdempotencyStartOut::Completed(cached) => {
let status = cached.context
.as_ref()
.and_then(|m| m.get("status_code"))
.and_then(|s| s.parse::<u16>().ok())
.and_then(|c| StatusCode::from_u16(c).ok())
.unwrap_or(StatusCode::OK);
Ok(Response::builder()
.status(status)
.body(Body::from(cached.response))
.unwrap())
}
}
}Register the middleware when building your Axum router:
use axum::middleware;
let app = Router::new()
.route("/charge", post(charge_handler))
.layer(middleware::from_fn_with_state(diom_client, idempotency_middleware));Python (FastAPI)
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
from diom import DiomAsync
from diom.models import IdempotencyAbortIn, IdempotencyCompleteIn, IdempotencyStartIn
class IdempotencyMiddleware(BaseHTTPMiddleware):
def __init__(self, app, diom: DiomAsync) -> None:
super().__init__(app)
self.diom = diom
async def dispatch(self, request: Request, call_next) -> Response:
idem_key = request.headers.get("idempotency-key")
if idem_key is None:
return await call_next(request)
result = await self.diom.idempotency.start(
idem_key,
IdempotencyStartIn(lock_period_ms=15_000),
)
if result.status == "started":
response = await call_next(request)
# Buffer the body — body_iterator is consumed once
body = b"".join([chunk async for chunk in response.body_iterator])
if response.status_code >= 400:
# Release the lock so the caller can retry
await self.diom.idempotency.abort(idem_key, IdempotencyAbortIn())
return Response(
content=body,
status_code=response.status_code,
headers=dict(response.headers),
)
context = {"status_code": str(response.status_code)}
# You can also store response headers here as additional entries, e.g.:
# context["x-request-id"] = response.headers.get("x-request-id", "")
retention_ms = 12 * 60 * 60 * 1000 # 12 hours
await self.diom.idempotency.complete(
idem_key,
IdempotencyCompleteIn(response=body, ttl_ms=retention_ms, context=context),
)
return Response(
content=body,
status_code=response.status_code,
headers=dict(response.headers),
)
elif result.status == "locked":
# A concurrent request with the same key is in flight
return Response(status_code=423)
else: # completed
ctx = result.data.context or {}
return Response(
content=result.data.response,
status_code=int(ctx.get("status_code", "200")),
)Register the middleware when creating your FastAPI app:
from diom import DiomAsync
from fastapi import FastAPI
diom = DiomAsync(token="...")
app = FastAPI()
app.add_middleware(IdempotencyMiddleware, diom=diom)JavaScript (Next.js)
Next.js does have a middleware.ts file, but it runs on the Edge Runtime which doesn’t support arbitrary Node.js packages. App Router API routes also don’t have a built-in middleware chain, so the idiomatic pattern is a higher-order function that wraps each handler:
import { NextRequest, NextResponse } from "next/server";
import { Diom } from "diom";
const diom = new Diom(process.env.DIOM_TOKEN!);
type RouteHandler = (req: NextRequest) => Promise<NextResponse>;
export function withIdempotency(handler: RouteHandler): RouteHandler {
return async (req: NextRequest): Promise<NextResponse> => {
const idemKey = req.headers.get("idempotency-key");
if (!idemKey) {
return handler(req);
}
const result = await diom.idempotency.start(idemKey, { lock_start_ms: 15_000 });
if (result.status === "started") {
const response = await handler(req);
const body = await response.arrayBuffer();
if (!response.ok) {
// Release the lock so the caller can retry
await diom.idempotency.abort(idemKey, {});
return new NextResponse(body, {
status: response.status,
headers: response.headers,
});
}
const context: Record<string, string> = { status_code: String(response.status) };
// You can also store response headers here as additional entries, e.g.:
// context["x-request-id"] = response.headers.get("x-request-id") ?? "";
const retentionMs = 12 * 60 * 60 * 1000; // 12 hours
await diom.idempotency.complete(idemKey, {
response: Array.from(new Uint8Array(body)),
context,
ttlMs: retentionMs,
});
return new NextResponse(body, {
status: response.status,
headers: response.headers,
});
} else if (result.status === "locked") {
// A concurrent request with the same key is in flight
return new NextResponse(null, { status: 423 });
} else {
// completed — return the cached response
const statusCode = parseInt(result.data.context?.status_code ?? "200", 10);
return new NextResponse(Buffer.from(result.data.response), {
status: statusCode,
});
}
};
}Apply the wrapper to any route handler that needs idempotency:
// app/api/charge/route.ts
import { withIdempotency } from "@/lib/idempotency";
export const POST = withIdempotency(async (req) => {
// ... your handler logic
});