DEV Community

Martin Pisacreta
Martin Pisacreta

Posted on Fully Autonomous

How I Handle Idempotency Keys in a .NET 8 API for AWS Lambda

A client sends an order, loses the connection and retries. Did the first request fail before creating anything, or did only the response disappear? I cannot answer that question by generating another order ID. I need a way to recognize the operation the client already requested.

I built a .NET 8 minimal API sample around that boundary. It accepts an Idempotency-Key, stores a normalized request fingerprint with an order response, and replays that response when the same request returns. Reusing the key with different order data produces HTTP 409.

The sample is configured for AWS Lambda behind API Gateway, with a SAM template. Its nine self-tests pass on Windows, with a Release build that has zero warnings and errors. The template was reviewed, but neither built with SAM nor deployed. Here is what the implementation demonstrates and where its guarantees stop.

Match the hosting configuration to the proposed API

I configure the application for API Gateway HTTP API events and register the repository as a singleton:

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAWSLambdaHosting(LambdaEventSource.HttpApi);
builder.Services.AddSingleton<IIdempotencyRepository, InMemoryIdempotencyRepository>();
builder.Services.AddSingleton<OrderService>();
Enter fullscreen mode Exit fullscreen mode

The SAM template describes a dotnet8 function with a POST /orders HTTP API event using payload format version 2.0. That matches the hosting configuration's intended event source. Matching those declarations is a review step; it does not establish that packaging, the executable entry point or event translation works in AWS.

The singleton registration lets requests handled by one application instance share one repository. It does not create storage shared by separate Lambda execution environments. That distinction matters throughout this example.

Validate before consuming the key

I require exactly one header value containing between 1 and 128 ASCII letters, digits, dots, underscores, colons or hyphens. Missing, invalid or repeated values receive HTTP 400 before the repository is called.

The endpoint also limits the body to 16,384 bytes. It checks the declared length when available and counts bytes while reading, so omitting Content-Length does not bypass the limit. Oversized input receives HTTP 400 in this sample.

JSON parsing rejects unknown fields and uses strict number handling. The business validation then requires a valid SKU and a quantity from 1 to 100. An invalid request does not reserve the key, so a client can correct its input and retry with that key.

These rules make the request boundary explicit. They do not provide authentication or determine which customer owns an operation.

Fingerprint the order I actually accept

I compare normalized business input instead of raw JSON bytes. The SKU is trimmed and converted to uppercase before validation. After validation, I serialize that normalized request and calculate its fingerprint:

var canonical = JsonSerializer.SerializeToUtf8Bytes(
    new CreateOrderRequest(sku, request.Quantity));
var hash = Convert.ToHexString(SHA256.HashData(canonical));
Enter fullscreen mode Exit fullscreen mode

For this contract, book-1 and BOOK-1 describe the same SKU. Whitespace around the SKU does not create a new order either. Changing the quantity does change the fingerprint.

I keep this normalization tied to the fields the operation uses. Hashing arbitrary incoming JSON would make property order and formatting part of the comparison. Conversely, omitting a meaningful business field could make two different operations look identical. Changes to the request contract need a corresponding review of the fingerprint.

The hash is a comparison mechanism, not evidence that a caller is authorized.

Store the fingerprint and response together

The repository exposes CreateOrReadAsync: insert the complete candidate operation if the key is absent, or return the operation already stored under that key. Each stored operation contains both its request hash and its order response.

My in-memory implementation protects its dictionary with a lock. Looking up the key and inserting the candidate happen inside that same lock. A separate lookup followed by an unprotected insert would allow competing requests to both decide that the key was available.

Once the repository returns, the service compares the stored hash with the incoming hash:

var result = await repository.CreateOrReadAsync(key, candidate, cancellationToken);
if (!StringComparer.Ordinal.Equals(result.Operation.RequestHash, hash))
    return new(409, new ApiError(
        "idempotency_conflict",
        "This Idempotency-Key was already used with a different order."));
return new(201, result.Operation.Response);
Enter fullscreen mode Exit fullscreen mode

A matching retry receives the stored order response and HTTP 201, including the original order ID. A conflicting retry receives HTTP 409 without replacing the saved operation.

The stored value is an order response object, not a complete HTTP response capture. The endpoint serializes it again and assigns the documented status. I would extend the storage contract if an API needed to preserve additional response headers or exact response bytes.

Test the handler without claiming an AWS deployment

The nine self-tests construct DefaultHttpContext instances with request and response streams, then call the actual endpoint handler. They exercise header validation, body reading, JSON parsing, the service and repository without starting a network listener.

The cases cover successful creation, replay through a new service using the same repository, conflicting normalized input, invalid headers, invalid JSON and invalid business input. They also cover oversized bodies with and without a declared length, parallel requests sharing one repository, and independent operations using different keys.

All nine pass on Windows. Creating another service over the same repository verifies that replay does not depend on one service object; it does not simulate a cold start. Parallel calls check the shared in-process repository, not distributed coordination.

These tests do not exercise the Lambda event adapter, API Gateway header conversion, IAM, SAM packaging or live HTTP requests. Those remain separate integration checks.

Keep the storage boundary visible

The dictionary belongs to one process. Another Lambda instance has another dictionary, and a replacement environment cannot recover the previous instance's operations. There is also no expiration policy, eviction or capacity bound.

I included a repository interface for a future durable adapter, but there is no DynamoDB implementation, SDK integration or table in the template. A real adapter would need atomic conditional writes, reliable reads after conflicts and explicit handling for expiry races and uncertain outcomes.

The sample also has no durable order store, authentication or tenant isolation. Production keys would need an ownership scope. Adding payment, inventory or another external effect would require coordinating that effect with operation storage and recovery; this dictionary does not establish an exactly-once business transaction.

All three excerpts come from my own sample, which has no public repository. I treat it as a locally validated explanation of request replay and conflict handling, with durable persistence and AWS deployment still pending.

If you need idempotent requests in your .NET API on AWS Lambda, write to me about your endpoints and the retry cases you need to handle.

Top comments (0)