How to use ETag for concurrency in ASP.NET Core

In this detailed guide, let's look at how we can detect and prevent mid-air edit collisions using ETag in an ASP.NET Core (.NET 6) Web API.

In a multi-client architecture, more than one client can attempt to update a server resource. In such cases, we need to ensure that the changes made by one client should not be overwritten by the other holding older version of the same entity.

What is a Mid-air Edit Collision?

For example, let’s consider an item which is being accessed by two clients. Assuming that the first client Client#1 has edited and POSTed its changes onto the server and after sometime Client#2 tries to submit its changes onto the server. But Client#2 has an older version of the item which has already been modified by Client#1. This means that if Client#2 changes are submitted, then the changes Client#1 has already done would be lost. This scenario is called as “Mid-air Edit Collisions”.

This is an important scenario in a multi-client system, where more than one client may modify the server resource. In such scenarios, before a resource is modified onto the server, an ETag mechanism ensures that the client possesses the latest version of the resource that is present on the server and if it doesn’t the changes that the client submits aren’t allowed.

What is an ETag?

An ETag or an Entity Tag is an opaque string that the server passes onto the client, which represents a unique version of the resource that the client possesses. The client stores the ETag on its side and passes this string in all its consecutive calls on the resource.

This approach solves two problems:

  1. Mid-air Edit Collisions
  2. Unchanged Resource caching

You can find detailed guide on how Resource caching is implemented using ETag in ASP.NET Core here.

How does ETag helps solve these collisions? The below sequence explains in detail.

How does ETag solve Mid-air edit collisions?

For two clients Client#1 and Client#2, the entire flow happens as below:

  1. Client#1 pulls data from the server, which returns an ETag representing some version of the server resource
  2. Client#2 pulls data from the server, which also returns an ETag representing some version of the server resource
  3. Client#1 makes some changes to the resource and WRITEs changes to the server, it passes the Etag it has in the request header “If-Match”.
  4. The Server computes an ETag with the resource it currently possesses, and matches it with the Etag passed by Client#1.
  5. If the tags match, the Server proceeds with the operation and returns a success StatusCode (200 OK).
  6. Now Client#1 still has older data, and when it WRITEs to the server it’s overwriting changes of Client#1. So it too passes its Etag in the “If-Match” header.
  7. The Server computes an ETag with the resource it currently possesses (updated by Client#1), and matches it with the Etag passed by Client#2.
  8. If the tags won’t match, the Server responds with StatusCode 412 (Precondition Failed) to Client#2, asking it to refresh its data and try again.

How to implement ETag and validating in ASP.NET Core WebAPI

As mentioned in the above points, the simplest way to implement is to compute the ETag just before the request is received at the API resource and then validating it against the incoming value. In an ASP.NET Core WebAPI, we can use an ActionFilter for the same purpose.

To demonstrate, let’s assume we have an API that updates Item properties for a specified ItemId. Let’s assume that the record contains a “LastModified” property which is strictly modified for every WRITE request.

The entity Item looks like below:

namespace ContainerNinja.Contracts.Data.Entities
{
    public abstract class BaseEntity
    {
        public virtual int Id { get; set; }
        public virtual DateTime LastModified { get; set; }
    }

    public class Item : BaseEntity
    {
        public string Name { get; set; }
        public string Description { get; set; }
        public string Categories { get; set; }
        public string ColorCode { get; set; }
    }
}

A simple Update() API receives the payload from the client and updates the Item entity in the database. The endpoint looks like below:

namespace ContainerNinja.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class ItemsController : ControllerBase
    {
        private readonly IMediator _mediator;

        public ItemsController(IMediator mediator)
        {
            _mediator = mediator;
        }

        [HttpPut]
        [Route("{id}")]
        [TypeFilter(typeof(ETagFilter))]
        [ProducesResponseType(typeof(CreateOrUpdateItemDTO), (int)HttpStatusCode.OK)]
        [ProducesErrorResponseType(typeof(BaseResponseDTO))]
        public async Task<IActionResult> Update(
            int id, [FromBody] CreateOrUpdateItemDTO model)
        {
            try
            {
                var command = new UpdateItemCommand(id, model);
                var response = await _mediator.Send(command);
                return Ok(response);
            }
            catch (InvalidRequestBodyException ex)
            {
                return BadRequest(
                    new BaseResponseDTO { 
                        IsSuccess = false, 
                        Errors = ex.Errors });
            }
            catch (EntityNotFoundException ex)
            {
                return NotFound(
                    new BaseResponseDTO { 
                        IsSuccess = false, 
                        Errors = new string[] { ex.Message } }
                );
            }
        }
    }
}

The API updates data on the backend using CQRS pattern, via Mediator library. You can find more information about CQRS and MediatR here.

The API is decorated with a custom ActionFilter [ETagFilter] that encapsulates the functionality to compute and validate ETag request header if available.

Within this filter, we capture the request that is produced by the API and then pull data from the datastore for the incoming ItemId to compute the ETag. I’m using the entire item object to compute Hash and use it as my ETag.

How this approach works?

  1. Client makes a POST/PUT request to the API with the payload and passes the ETag it possess in the “If-Match” request header
  2. In the API, we receive the request payload within the ActionFilter and look out for any ETag if passed.
  3. If the request contains an ETag, we fetch the item available in the datastore and compute ETag with the data present in the server
  4. We compare the ETag thus computed with the ETag received in the request header
  5. If the ETags don’t match we return 412 (Precondition Failed) back to the client
  6. Else the call continues to the API and the data is updated in the datastore, which results in a new ETag when computed

The functionality looks like below:

private async Task<bool> ValidateETagForMidAirEditsCollision(
        ActionExecutingContext executingContext)
{
    var request = executingContext.HttpContext.Request;
    var incomingETag = request.Headers[HeaderNames.IfMatch];

    object itemId;
    if (request.RouteValues.TryGetValue("id", out itemId))
    {
        var command = new GetItemByIdQuery(Convert.ToInt32(itemId));
        var result = await _mediator.Send(command);

        if (result != null)
        {
            // generates ETag from the entire response Content
            var etag = GenerateEtagFromResponseBodyWithHash(result);

            // if both the etags are not equal
            // the data has already changed on the server side
            // mid-air collision
            // respond with a 412
            if (!incomingETag.Equals(etag))
            {
                executingContext.Result = new StatusCodeResult(
                        (int)HttpStatusCode.PreconditionFailed);

                return false;
            }
        }
    }

    return true;
}

The ActionFilter [ETagFilter] looks like below:

namespace ContainerNinja.API.Filters
{
    // prevents the action filter methods to be invoked twice
    [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = false)]
    public class ETagFilter : ActionFilterAttribute, IAsyncActionFilter
    {
        private readonly IMediator _mediator;

        public ETagFilter(IMediator mediator)
        {
            _mediator = mediator;
        }

        public override async Task OnActionExecutionAsync(
            ActionExecutingContext executingContext,
            ActionExecutionDelegate next
        )
        {
            var request = executingContext.HttpContext.Request;

            // Computing and Validating ETags for Edit Collisions on POST/PUT requests
            if (
                !request.Method.Equals(HttpMethod.Get)
                && request.Headers.ContainsKey(HeaderNames.IfMatch)
            )
            {
                if (!await ValidateETagForMidAirEditsCollision(executingContext))
                {
                    return;
                }
            }

            var executedContext = await next();

            var response = executedContext.HttpContext.Response;

            // Computing ETags for Response Caching on GET requests
            if (
                request.Method == HttpMethod.Get.Method
                && response.StatusCode == (int)HttpStatusCode.OK
            )
            {
                ValidateETagForResponseCaching(executedContext);
            }
        }
    }
}

Testing the Mechanism

The below are a series of cURL requests and their responses, which demonstrate the series of above mentioned points. Assuming that there are two Clients #1 and #2 which have already received the resource with the same ETag. Let’s see how this works:

  1. Client#1 PUTs its edits to the server. It passes its local ETag in its request header (If-Match). The server checks for the ETag and since the resource hasn’t changed it modifies the resource and returns a 200 OK.
curl --location --request PUT 'https://localhost:5001/api/Items/3' 
--header 'Content-Type: application/json' 
--header 'If-Match: M0nL2zbZOAw/XwguC6Lf9q96E3W5DEh14CYZdvokFSA=' 
--data-raw '{
  "name": "Shuriken",
  "description": "Throwing weapons are unique and famous in the Ninjitsu.",
  "categories": "Weaponry",
  "colorCode": "Black"
}'

< HTTP/1.1 200 OK
< Content-Type: application/json; charset=utf-8
< Date: Thu, 09 Dec 2021 15:44:55 GMT
< Server: Kestrel
< Transfer-Encoding: chunked
<
{
    "name":"Shuriken",
    "description":"Throwing weapons are unique and famous in the Ninjitsu.",
    "categories":"Weaponry",
    "colorCode":"Black",
    "id":3,
    "lastModified":"2021-12-09T21:14:56.0992688+05:30"
}
  1. Client#2 PUTs its edits to the server, which has already been modified by Client#1. It too passes its local ETag in its request header (If-Match). The server checks for the ETag and computes an ETag from the resource in the server. The Tags don’t match (since its now modified). Server sends back an empty response with 412 (Precondition Failed).
curl --location --request PUT 'https://localhost:5001/api/Items/3' 
--header 'Content-Type: application/json' 
--header 'If-Match: RkKoHlIIDsVMR0hwSMChWZ5N2ZxNAJk8lDfRLv0UjOo=' 
--data-raw '{
  "name": "Shuriken",
  "description": "Shuriken is also called a ninja star or throwing star.",
  "categories": "Weaponry,Ninjitsu",
  "colorCode": "Kuro"
}'

< HTTP/1.1 412 Precondition Failed
< Content-Type: application/problem+json; charset=utf-8
< Date: Thu, 09 Dec 2021 15:48:17 GMT
< Server: Kestrel
< Transfer-Encoding: chunked
<
{"status":412,"traceId":"00-fed832c06f24455366f3cfa30b64e19b-e6af4a8821cdaafc-00"}
  1. Client#2 now GETs the resource from the server with its local ETag. Server sends the updated data with a new ETag. Client#2 updates its local cache and ETag.
curl --location --request GET 'https://localhost:5001/api/Items/3'

< HTTP/1.1 200 OK
< Content-Type: application/json; charset=utf-8
< Date: Thu, 09 Dec 2021 15:50:02 GMT
< Server: Kestrel
< ETag: tVWhjZEQ6sVl+ONN+4nwrqb7c/VxFWepP+vdfJtmnoY=
< Transfer-Encoding: chunked
<
{
    "name":"Shuriken",
    "description":"Throwing weapons are unique and famous in the Ninjitsu.",
    "categories":"Weaponry",
    "colorCode":"Black",
    "id":3,
    "lastModified":"2021-12-09T21:14:56.0992688"
}
  1. Client#2 PUTs its edits to the server, and passes the latest ETag. server checks for the ETag and validates it. Since its the latest the changes are committed and Client#2 receives a 200 OK response from the server.
curl --location --request PUT 'https://localhost:5001/api/Items/3' 
--header 'Content-Type: application/json' 
--header 'If-Match: tVWhjZEQ6sVl+ONN+4nwrqb7c/VxFWepP+vdfJtmnoY=' 
--data-raw '{
  "name": "Shuriken",
  "description": "Shuriken is also called a ninja star or throwing star.",
  "categories": "Weaponry,Ninjitsu",
  "colorCode": "Kuro"
}'

< HTTP/1.1 200 OK
< Content-Type: application/json; charset=utf-8
< Date: Thu, 09 Dec 2021 15:51:23 GMT
< Server: Kestrel
< Transfer-Encoding: chunked
<
{
    "name":"Shuriken",
    "description":"Shuriken is also called a ninja star or throwing star.",
    "categories":"Weaponry,Ninjitsu",
    "colorCode":"Kuro",
    "id":3,
    "lastModified":"2021-12-09T21:21:24.4366499+05:30"
}

Conclusion

In a multi-client architecture, we may have situations where more than one client attempts to update a record. In such cases, we need to ensure that the changes made by one client should not be overwritten by the other and ensure concurrency.

We can use ETag that represents a particular state of the record to ensure that whatever edit is being submitted by a user is the latest and has not yet been updated by any other user.

This we implement using the If-None-Match request header through which we pass the ETag that represents the last state of the record returned by the server and if there’s any collision detected the system can reject the submit and ask to refresh the data. This helps in avoiding any potential collision.


Buy Me A Coffee

Found this article helpful? Please consider supporting!

Ram
Ram

I'm a full-stack developer and a software enthusiast who likes to play around with cloud and tech stack out of curiosity. You can connect with me on Medium, Twitter or LinkedIn.

Leave a Reply

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