REST Architecture: UUIDs & Idempotency
In distributed systems and RESTful APIs, UUIDs and Idempotency are critical patterns used to ensure data integrity, prevent duplicate resource creation, and gracefully handle network failures.
1. Resource Identification (Where UUID Fits)
In a RESTful architecture, every resource must be uniquely identifiable via a URI. While traditional systems used auto-incrementing integers (/users/1), modern REST APIs favor UUIDs (Universally Unique Identifiers).
Key Benefits of UUIDs in REST
- Client-Side Generation: Clients can generate a UUID locally and send it in a
POSTrequest. The client doesn’t have to wait for the database to assign an ID.- Security: Unlike sequential IDs, UUIDs are non-guessable. This prevents attackers from scraping data by guessing the next integer in a URL sequence.
- Decoupling: Ideal for distributed microservices where multiple databases need to generate unique IDs without talking to a single centralized counter.
REST Example
GET /api/v1/orders/8f5da7d2-43fa-4e76-88a9-4673c68fb7612. HTTP Method Contracts (Where Idempotency Fits)
An API operation is idempotent if making the same request multiple times yields the same server state as making it exactly once.
The REST Method Matrix
HTTP Method Idempotent? Safe? Description GETYes Yes Read-only. Fetching a resource 100 times changes nothing. PUTYes No Replaces a resource entirely. Repeated calls yield the exact same final state. DELETEYes No The first call deletes the resource. Subsequent calls return 404 Not Found, but the data remains gone.POSTNo No Natively non-idempotent. Sending POST /ordersthree times creates three separate charges.
3. The Intersection: Fixing Natively Non-Idempotent POST Requests
Because network connections are unreliable, a client might send a POST request, but the network drops before the server can return a response. The client doesn’t know if the request succeeded or failed.
To safely retry a POST request without causing duplicate side effects (like double-charging a credit card), REST APIs use a UUID as an Idempotency Key.
The Workflow Diagram
sequenceDiagram autonumber actor Client participant API Gateway / Middleware participant Database / Cache Note over Client: Step 1: Client generates unique UUID<br/>Idempotency-Key: 9b1deb4d... Client->>API Gateway: POST /charges (with Idempotency-Key) API Gateway->>Database / Cache: Check if Key exists alt Key Not Found (First Request) Database / Cache-->>API Gateway: Lock key & allow execution Note over API Gateway: Process payment & save response payload API Gateway->>Database / Cache: Store Key + Saved Response Status API Gateway-->>Client: 201 Created (Network drops here! Client never sees it) else Key Found (Retried Request) Database / Cache-->>API Gateway: Key exists! Return saved response API Gateway-->>Client: 200 OK or 201 Created (Instantly returned from cache) end
The Workflow Diagram
sequenceDiagram autonumber actor Client participant API Gateway / Middleware participant Database / Cache note over Client: Step 1: Client generates unique UUID<br>Header: Idempotency-Key: 9b1deb4d... Client->>API Gateway: POST /charges (with Idempotency-Key) API Gateway->>Database / Cache: Check if Key exists / Try Lock alt Key Not Found (First Request) Database / Cache-->>API Gateway: Key empty -> Acquire lock Note over API Gateway: Process payment & save response payload API Gateway->>Database / Cache: Store Key + Saved Response Status & Body API Gateway-->>Client: 201 Created (Network drops here! Client never sees it) else Key Found (Concurrent Processing / Race Condition) Database / Cache-->>API Gateway: State is 'PROCESSING' API Gateway-->>Client: 409 Conflict (Back off and retry later) else Key Found (Subsequent Completed Retry) Database / Cache-->>API Gateway: Key exists -> Return saved response API Gateway-->>Client: 200 OK / 201 Created (Instantly returned from cache) end
Cache Best Practice
When implementing an idempotency cache (like Redis) for
POSTrequests, always set a Time-To-Live (TTL) on the UUID key (e.g., 24 to 48 hours). This prevents your cache database from growing indefinitely while safely covering the retry window.
4. Advanced Scenarios: Race Conditions & Status Codes
When implementing idempotency keys, you must account for timing vulnerabilities and edge cases.
Handling Concurrent Race Conditions
If a user rapidly double-clicks a “Submit” button, or a client script mistakenly fires two identical retries at the exact same millisecond, the server will process both requests simultaneously.
The Race Condition Risk
If Request A and Request B hit the API at the same time, the server might check the cache for the UUID key, find nothing (since neither has finished), and proceed to execute the business logic twice, resulting in a double-charge.
The Solution: Distributed Locking
To fix this, use an atomic “Set-If-Not-Exists” (NX) operation in your cache layer (e.g., Redis SETNX) to create an immediate execution lock.
- The server receives the request and attempts to acquire a lock using the UUID.
- If the lock fails because the UUID is already being processed, the server instantly rejects the second request.
Which HTTP Status Codes to Return?
Depending on when the retry arrives, your API must respond with distinct, semantically accurate HTTP status codes:
HTTP Status Code Selection Matrix
Scenario Target HTTP Status Meaning & Behavior First Request Success 201 CreatedThe resource was created successfully for the first time. Subsequent Successful Retry 200 OKor201 CreatedThe request was previously completed. Return the cached payload from the first request so the client thinks it succeeded seamlessly. Retry While First is Still Processing 409 Conflict(or423 Locked)The server is actively working on the first request. The client must back off and try again later. Payload Mismatch 400 Bad RequestThe client sent the same UUID idempotency key, but changed the underlying request body (e.g., changed the amount from $10 to $100). This is an invalid operation.
5. Implementations
Here is how you can implement this pattern natively using Middleware in .NET and an Action Filter/Interceptor approach in Java (Spring Boot) using a pseudo-cache logic pattern.
Option A: .NET Core Middleware Implementation
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Caching.Distributed;
using System.Text.Json;
public class IdempotencyMiddleware
{
private readonly RequestDelegate _next;
private readonly IDistributedCache _cache;
public IdempotencyMiddleware(RequestDelegate next, IDistributedCache cache)
{
_next = next;
_cache = cache;
}
public async Task InvokeAsync(HttpContext context)
{
// Only process POST requests for idempotency
if (context.Request.Method != HttpMethods.Post ||
!context.Request.Headers.TryGetValue("Idempotency-Key", out var itemKey))
{
await _next(context);
return;
}
string key = itemKey.ToString();
// Try atomic assignment to mimic Redis SETNX / Distributed Lock
var cachedResponse = await _cache.GetStringAsync(key);
if (cachedResponse == "PROCESSING")
{
context.Response.StatusCode = StatusCodes.Status409Conflict;
await context.Response.WriteAsync("Request is already being processed.");
return;
}
if (cachedResponse != null)
{
// Key found, return cached response status & data
var savedResponse = JsonSerializer.Deserialize<CachedResponseModel>(cachedResponse);
context.Response.StatusCode = savedResponse.StatusCode;
context.Response.ContentType = "application/json";
await context.Response.WriteAsync(savedResponse.Body);
return;
}
// Lock the key locally/in-cache while processing
await _cache.SetStringAsync(key, "PROCESSING", new DistributedCacheEntryOptions {
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(2)
});
// Intercept response stream to save result
var originalResponseBodyStream = context.Response.Body;
using var responseBody = new MemoryStream();
context.Response.Body = responseBody;
await _next(context);
// Read result, cache it permanently with practical TTL, and send back to client
context.Response.Body.Seek(0, SeekOrigin.Begin);
string responseText = await new StreamReader(context.Response.Body).ReadToEndAsync();
context.Response.Body.Seek(0, SeekOrigin.Begin);
var resultToCache = new CachedResponseModel {
StatusCode = context.Response.StatusCode,
Body = responseText
};
await _cache.SetStringAsync(key, JsonSerializer.Serialize(resultToCache), new DistributedCacheEntryOptions {
AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(24)
});
await responseBody.CopyToAsync(originalResponseBodyStream);
}
}
public class CachedResponseModel
{
public int StatusCode { get; set; }
public string Body { get; set; }
}Option B: Java (Spring Boot) Interceptor Implementation
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.util.concurrent.TimeUnit;
@Component
public class IdempotencyInterceptor implements HandlerInterceptor {
private final StringRedisTemplate redisTemplate;
public IdempotencyInterceptor(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (!"POST".equalsIgnoreCase(request.getMethod())) {
return true;
}
String key = request.getHeader("Idempotency-Key");
if (key == null || key.isEmpty()) {
return true; // Or reject if Idempotency Key is strictly mandatory
}
// Try to atomically set "LOCK" status if key doesn't exist (equivalent to SETNX)
Boolean isNew = redisTemplate.opsForValue().setIfAbsent(key, "PROCESSING", 2, TimeUnit.MINUTES);
if (Boolean.FALSE.equals(isNew)) {
String currentStatus = redisTemplate.opsForValue().get(key);
if ("PROCESSING".equals(currentStatus)) {
response.setStatus(409); // Conflict
response.getWriter().write("{"
error ": "
Concurrent request in progress.
"}");
return false;
}
// If it exists but is not processing, it contains the cached response JSON payload
response.setStatus(200);
response.setContentType("application/json");
response.getWriter().write(currentStatus);
return false;
}
return true;
}
// Note: You would implement postHandle / afterCompletion to replace
// the "PROCESSING" state with the actual downstream body content JSON.
}