Concurrency control using HTTP headers
HTTP conditional requests are a special kind of request that, through the usage of concurrency control headers, prevent conflicting updates (race conditions or lost updates) in RESTful APIs or web applications. The headers define a precondition and whether the precondition is matched or not will govern the outcome of the request. The value of the headers is called a validator and it should contain a specific version of the resource on the server.
One of such validator is the ETag (entity tag) response header which hosts the identifier for a specific version of a resource. You can use it for optimizing caching (by pairing it with the If-None-Match header) and avoiding "mid-air collisions" (by pairing it with the If-Match header).
A simple implementation of conditional requests in a dotnet 9 minimal api requires 4 steps:
- The entity you are trying to apply conditional control to needs a way to generate its ETag
public record Resource
{
public string Id { get; init; } = string.Empty;
public string Content { get; init; } = string.Empty;
public DateTimeOffset LastModified { get; init; } = DateTimeOffset.UtcNow;
public long Version { get; init; } = 1;
public string GenerateETag()
{
using var hasher = System.Security.Cryptography.SHA256.Create();
var input = $"{Id}-{Content}-{Version}";
var bytes = System.Text.Encoding.UTF8.GetBytes(input);
var hash = hasher.ComputeHash(bytes);
return $"\"{Convert.ToHexString(hash).ToLowerInvariant()}\"";
}
}
- Implement an endpoint filter for adding the Etag header to conditional GET
public class ETagResponseFilter : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
// Execute the endpoint
var result = await next(context);
// If the result has a Resource, add ETag and Last-Modified headers
if (result is not IResult typedResult ||
typedResult.GetType().GetProperty("Value")?.GetValue(typedResult) is not Resource resource) return result;
var httpContext = context.HttpContext;
// Add ETag header
var etag = resource.GenerateETag();
httpContext.Response.Headers.ETag = etag;
// Check for If-None-Match header (conditional GET)
if (httpContext.Request.Headers.IfNoneMatch.FirstOrDefault() is { } ifNoneMatch &&
(ifNoneMatch == etag || ifNoneMatch == "*"))
{
return TypedResults.StatusCode(StatusCodes.Status304NotModified);
}
return result;
}
}
- Implement an endpoint filter for checking concurrency control headers on state changing operations
public class ConcurrencyControlFilter(ResourceStore store) : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
var httpContext = context.HttpContext;
// Only apply to state-changing methods
if (httpContext.Request.Method is not "PUT" and not "PATCH" and not "DELETE")
{
return await next(context);
}
// Extract resource ID from route
if (!context.HttpContext.Request.RouteValues.TryGetValue("id", out var idObj) || idObj is not string id)
{
return TypedResults.BadRequest("Resource ID is required");
}
// Get the resource
var resource = store.GetResource(id);
if (resource is null)
{
return TypedResults.NotFound();
}
// Check for concurrency headers
var ifMatch = httpContext.Request.Headers.IfMatch.FirstOrDefault();
// If no concurrency headers are present, return 428 Precondition Required
if (string.IsNullOrEmpty(ifMatch))
{
var problem = new ProblemDetails
{
Title = "Precondition Required",
Detail = "This request requires a conditional header (If-Match or If-Unmodified-Since)",
Status = StatusCodes.Status428PreconditionRequired
};
return TypedResults.Problem(problem);
}
// Check If-Match header
if (!string.IsNullOrEmpty(ifMatch) && ifMatch != "*")
{
var currentETag = resource.GenerateETag();
if (ifMatch != currentETag)
{
httpContext.Response.Headers.ETag = currentETag;
var problem = new ProblemDetails
{
Title = "Precondition Failed",
Detail = "The resource has been modified since you last retrieved it",
Status = StatusCodes.Status412PreconditionFailed
};
return TypedResults.Problem(problem);
}
}
// Store resource information so the handler can use it
httpContext.Items["CurrentResource"] = resource;
// Continue to the handler
return await next(context);
}
}
- Apply the filters to the route endpoints you want to enable conditional requests on
resourceGroup.MapGet("/{id}", (string id, ResourceStore store) =>
{
var resource = store.GetResource(id);
return resource is null
? Results.NotFound()
: Results.Ok(resource);
})
.AddEndpointFilter<ETagResponseFilter>();
resourceGroup.MapPut("/{id}", async (string id, ResourceUpdateRequest request, HttpContext httpContext, ResourceStore store) =>
{
try
{
// Retrieve the resource validated by the filter
if (httpContext.Items["CurrentResource"] is not Resource currentResource)
{
return Results.BadRequest("Concurrency context missing");
}
// Update the resource
var updated = store.UpdateResource(id, request.Content, currentResource.Version);
// Set ETag header in response
httpContext.Response.Headers.ETag = updated.GenerateETag();
return Results.Ok(updated);
}
catch (KeyNotFoundException)
{
return Results.NotFound();
}
catch (InvalidOperationException ex)
{
return Results.Conflict(ex.Message);
}
})
.AddEndpointFilter<ConcurrencyControlFilter>();
Testing the new endpoints can be done through a series of HTTP calls using HTTP Client:
@HttpCC_HostAddress = http://localhost:5167
### ETag enabled
GET {{HttpCC_HostAddress}}/api/resources/1/
Accept: application/json
### Response code: 428 (Precondition Required)
PUT {{HttpCC_HostAddress}}/api/resources/1
Content-Type: application/json
{
"content":"Updated content"
}
### Response code: 200 (OK)
PUT {{HttpCC_HostAddress}}/api/resources/1
Content-Type: application/json
If-Match: "26f9d3ad1a31d5591184d5707bee6ea19056ac888ffb3d8602b6eb8770406633"
{
"content":"Updated content"
}
### Response code: 412 (Precondition Failed);
PUT {{HttpCC_HostAddress}}/api/resources/1
Content-Type: application/json
If-Match: "d56c245c2b8e75e04c7fc2a6a6a969f84fc5acff4bf0ce5e0f60dff65106f665"
{
"content":"Updated content"
}
The same principles apply if you want to implement the less accurate version of ETag, which is the Last-Modified header and it's accompanying conditional headers, If-Modified-Since and If-Unmodified-Since.
The full implementation contains both conditionals and it can be seen here.