Implementing idempotency in .NET Core can be accomplished with a filter, which I will cover at a high level.
Putting it simply, idempotency is the concept of executing an operation multiple times and getting the same result back, given the same input parameters for each request. In our case, we will be setting up idempotency for an API endpoint.
Idempotence Key
To implement idempotency in your APIs (or any other process), we will need a unique identifier or an idempotence key.
The calling client can send the idempotence key from a browser or another API (backend to backend). In this case, the calling client can send this impotence key via a custom header (i.e. X-Idempotence-Key
) or in the body of the request.
Another alternative is to use the data already present in the body of the request as the unique identifier. Either by taking the entire request body or a unique identifier within the body.
For the rest of this article, let’s say the client is sending a request with the following request body:
{ "clientId": "26622324-b9eb-4d7f-bd24-fb1a1b21e3b0" "transactionId": "234", "profile": { "firstName": "Esau", "lastName": "Silva", "email": "esau@silva.com" }, "achAccount": { "aba": "123456789", "checkNumber": "0123", "accountNumber": "45678903", "amountInCents": 15000 } }
Based on this request, we can take the clientId
and transactionId
as the idempotency key instead of having the client send us additional data.
Given the nature that transactionId
is a unique value coming from the client and the clientId
is a unique value in our system. This combination of request fields will give us a unique identifier across our system.
High-Level Implementation
Say we want to add the idempotency filter to a POST endpoint with the following route: /deposit/ach
// Progam.cs app.MapPost("/deposit/ach", Deposit.PostAch) .AddEndpointFilter<IdempotencyFilter>();
The implementation of Deposit.PostAch
does not matter much, but below is the shell of the method
public static class Deposit { public static async Task<IResult> PostAch ( HttpContext context, RequestModel request, DepositService depositService, CancellationToken cancellationToken ) { // Implenentation details var response = await depositService.PerformDeposit(request, cancellationToken); return Results.Ok(response); } }
Now, the implementation of the Idempotency Filter.
One thing to note is the isInFlight
flag, which represents a request that has not yet finished processing. Say, the client sends a request for the first time, and the request takes some time to process. There is a network failure that prompts the client to send a subsequent retry request, the isInFlight
flag would be true and the API would return a 409 Status Code, indicating that the request is still processing.
At this point, the client has the option to wait a second or so before sending the same request again to get the idempotent response.
public sealed class IdempotencyFilter : IEndpointFilter { private readonly IIdempotencyService _idempotencyService; // You an use DI as needed public IdempotencyFilter(IIdempotencyService idempotencyService) { _idempotencyService = idempotencyService; } public async ValueTask<object?> InvokeAsync( EndpointFilterInvocationContext context, EndpointFilterDelegate next) { // context.Arguments will have the request in its items. In here we are getting the item that is // of type RequestModel, and if in the case there is no request, it will just continue to the next filter // or proceed to executing the endpoint. if (context.Arguments.FirstOrDefault(o => o is RequestModel) is not RequestModel request) return await next(context); // If you are having the client send the idempotence key via a custom header, you can retrieve it from the // HttpContext as follows. If the header is not present, we can return a 400 Bad Request response. // However, in our example, we are getting the idempotence key from the request itself. context.HttpContext.Request.Headers.TryGetValue("X-Idempotence-Key", out var idempotenceKey); // We call the _idempotencyService to check if the request is idempotent or return true for isInFlight var (idempotentResponse, isInFlight) = await GetIdempotentResponseOrInFlight(request); // We return a 409 Conflict if the request is still processing, or in flight. if (isInFlight) { var problemDetails = new ProblemDetails { Type = "https://httpstatuses.com/409", Title = "Request in flight.", Status = StatusCodes.Status409Conflict, Instance = context.HttpContext.Request.Path }; return Results.Problem(problemDetails); } // If the request is idempotent, we return the idempotent response. We are also sending a custom header // indicating the response is idempotent. if (idempotentResponse is not null) { context.HttpContext.Response.Headers["X-Idempotent"] = "true"; return Results.Ok(idempotentResponse); } // If the request is not idempotent, we continue to the next filter or proceed to executing the endpoint. // The result variable will have the endpoint response, including the status code and response value. var result = await next(context); // We just want to return the result if the response is not a 2xx status code. This is because, say // the response was a 400 Bad Request, then there is no point in moving forward, subsequent requests // will fail API validations. if (result is not IStatusCodeHttpResult { StatusCode: >= 200 and < 300 }) return result; /* This is where you would use _idempotencyService to persist the response if the request is idempotent. Depending on the strategy you are using, you can cache response or persist it in the database. */ // Casting the result to a IValueHttpResult to get the response value. var httpResult = (IValueHttpResult)result!; var value = httpResult.Value; return result; } private async Task<(ResponseModel? IdempotentResponse, bool InFlight)> GetIdempotentResponseOrInFlight( RequestModel request) { /* The method returns the idempotent response and inFlight flag indicating the request is currently processing as a Tuple. This is where you would use _idempotencyService to check if the request is idempotent or return true for inFlight. Based on the idempotence key, which we get from the request: request.ClientId request.ClientTransactionId I would recommend to create a cache entry, or database entry, with the idempotence key before proceeding with the request. Depending on your use case, you can query the database and build the idempotent response or return the cached response. */ var (idempotentResponse, inFlight) = await _idempotencyService .LoadIdempotentTransaction(request.ClientId, request.ClientTransactionId); return (idempotentResponse, inFlight); } }
If you liked this reading, share it on your social media and you can follow me on Twitter or LinkedIn.
Consider giving back by getting me a coffee (or a couple) by clicking the following button: