Implementing Idempotency in .NET Core

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:

Spread the love

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.