Every successful web application inevitably gets more users, and in that crowd of users, there is an individual who enthusiastically carries the torch of desktop apps in their heart. These torchbearers of the desktop have in their fingers the impulsive need to double-click every button. OS designers built Double-clicks into the UX language of all desktop operating systems. Still, the double-click can wreak havoc on unsuspecting web developers who assume all users will click once to submit forms.
This post will show a straightforward server-side technique to reduce, if not eliminate, a user’s duplicate requests while still keeping everyone, including us, happy in the process.
Let’s Talk About Idempotency
Originating from mathematics, the term idempotent
is jargon for saying, “we can apply this operation multiple times and still receive the same outcome”.
We have described a situation where a user may be double-clicking a form’s submit button, initiating multiple identical web requests. Exact requests can have harmless side-effects like sending a duplicate communication or can cause the stressful situation of withdrawing funds numerous times from an individual’s bank account.
The HTTP protocol has idempotency methods built into the specification, with GET
, OPTIONS
, and HEAD
all generally used for reading operations. Non-idempotent methods include POST
, PUT
, PATCH
, and DELETE
. The later methods tend to mutate state, whether creating a new resource, updating an existing one, or deleting it entirely.
So how do we make these non-idempotent methods idempotent-like?
Making Calls Idempotent (In Theory)
We need to send an Idempotency
token with every request that can cause a state change. Since we’re dealing with web applications, each form will generate a globally unique token when the browser loads the UI. This token will then be sent to our server and saved to a storage mechanism. As requests come in, we will verify the token against the storage mechanism to ensure we haven’t seen it before.
Ok, so how do we do that?
Implementing ASP.NET Core Idempotency By Resource
The first approach is to build idempotency into our resources. In this case, we’ll be using Entity Framework Core and the unique constraint available on an index. Let’s take a look at our Message
that we should only process once.
[Index(nameof(IdempotentToken), IsUnique = true)]
public class Message
{
public int Id { get; set; }
public string Text { get; set; }
public string IdempotentToken { get; set; }
}
We get to take advantage of the transactional features of our database engine. Using a database reduces our need to manage in-memory storage structures. Storing the idempotent token alongside our resource will also allow us to implement explicit logic for each resource-based scenario. We’ll see a general solution later but loses some error-handling opportunities.
We’ll be using Razor Pages, but we could apply this approach to MVC as well.
using System;
using System.Text.Json;
using System.Threading.Tasks;
using ContactForm.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace ContactForm.Pages
{
public class IndexModel : PageModel
{
private readonly ILogger<IndexModel> logger;
private readonly Database database;
[BindProperty] public string IdempotentToken { get; set; }
[BindProperty] public string Text { get; set; }
[TempData] public string AlertCookie { get; set; }
public Alert Alert =>
AlertCookie is not null
? JsonSerializer.Deserialize<Alert>(AlertCookie)
: null;
public IndexModel(ILogger<IndexModel> logger, Database database)
{
this.logger = logger;
this.database = database;
}
public void OnGet()
{
IdempotentToken = Guid.NewGuid().ToString();
}
public async Task<IActionResult> OnPost()
{
try
{
if (string.IsNullOrEmpty(IdempotentToken))
{
AlertCookie = Alert.Error.ToJson();
return Page();
}
database.Messages.Add(new Message
{
IdempotentToken = IdempotentToken,
Text = Text
});
// will throw if unique
// constraint is violated
await database.SaveChangesAsync();
TempData[nameof(AlertCookie)] =
new Alert("Successfully received message").ToJson();
// perform Redirect -> Get
return RedirectToPage();
}
catch (DbUpdateException ex)
when (ex.InnerException is SqliteException {SqliteErrorCode: 19})
{
AlertCookie = new Alert(
"You somehow sent this message multiple time. " +
"Don't worry its safe, you can carry on.",
"warning")
.ToJson();
}
catch
{
AlertCookie = Alert.Error.ToJson();
}
return Page();
}
}
public record Alert(string Text, string CssClass = "success")
{
public string ToJson()
{
return JsonSerializer.Serialize(this);
}
public static Alert Error { get; } = new(
"We're not sure what happened.",
"warning"
);
};
}
Let’s go through the code step-by-step:
- In the
OnGet
method, we generate a unique token usingGuid
, but this could be any unique value. We will use the value in our HTML form. - In our
OnPost
method, we attempt to save theIdempotentToken
along with ourText
value. If it is the first time we see the request, we will store it with no issues. - If we have already stored the token, we will get a
DbUpdateException
with an inner exception ofSqliteException
. The exception will depend on our database engine choice.
In our HTML, we need to make sure that the token is part of our form post.
<form method="post" asp-page="Index">
<div class="form-group">
<label asp-for="Text"></label>
<textarea class="form-control" asp-for="Text" rows="3"></textarea>
</div>
<input asp-for="IdempotentToken" type="hidden" />
<button type="submit" class="btn btn-primary mb-2">Send Message</button>
</form>
Let’s look at the three situations that can occur during a user’s experience on our site—starting with a successful request.
Looks good; what happens when we reuse a token?
Finally, what happens when we don’t include a token at all?
Great! Seems to be working, but what about a more general solution?
Idempotency Using ASP.NET Core Middleware
Since we’re still in ASP.NET Core, our other general option to use middleware to inspect POST
requests for a generic token entity in our database to shortcircuit incoming duplicate requests. First, we’ll create a storage mechanism for tokens using Entity Framework Core.
[Index(nameof(IdempotentToken), IsUnique = true)]
public class Requests
{
public int Id { get; set; }
public string IdempotentToken { get; set; }
public static string New()
{
return Guid.NewGuid().ToString();
}
}
Then we’ll need to implement a StopDuplicatesMiddleware
middleware.
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using ContactForm.Models;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace ContactForm.Pages
{
public class StopDuplicatesMiddleware : IMiddleware
{
private readonly string key;
private readonly string alertTempDataKey;
public StopDuplicatesMiddleware(string key = "IdempotentToken", string alertTempDataKey = "AlertCookie")
{
this.key = key;
this.alertTempDataKey = alertTempDataKey;
}
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
if (
context.Request.Method == HttpMethod.Post.Method &&
context.Request.Form.TryGetValue(key, out var values))
{
var token = values.FirstOrDefault();
var database = context.RequestServices.GetRequiredService<Database>();
var factory = context.RequestServices.GetRequiredService<ITempDataDictionaryFactory>();
var tempData = factory.GetTempData(context);
try
{
database.Requests.Add(new Requests
{
IdempotentToken = token
});
// we're good
await database.SaveChangesAsync();
}
catch (DbUpdateException ex)
when (ex.InnerException is SqliteException {SqliteErrorCode: 19})
{
tempData[alertTempDataKey] = new Alert(
"You somehow sent this message multiple time. " +
"Don't worry its safe, you can carry on.",
"warning")
.ToJson();
tempData.Keep(alertTempDataKey);
// a redirect and
// not an immediate view
context.Response.Redirect("/", false);
}
}
await next(context);
}
}
}
The middleware we wrote will store any token it receives in the database. If the token already exists, the call will throw an exception. In this case, we’ll keep an alert in our TempData
, which the Razor Page will use in our redirect.
As a final step, we’ll need to register our StopDuplicatesMiddleware
with the ASP.NET Core pipeline.
using ContactForm.Models;
using ContactForm.Pages;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace ContactForm
{
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
services.AddRazorPages();
services.AddEntityFrameworkSqlite();
services.AddDbContext<Database>();
services.AddSingleton<StopDuplicatesMiddleware>();
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/Error");
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthorization();
app.UseMiddleware<StopDuplicatesMiddleware>();
app.UseEndpoints(endpoints => { endpoints.MapRazorPages(); });
}
}
}
With the middleware registered, we get the same behavior across all forms with an input element with the name IdempotentToken
.
Conclusion
It’s always best practice to write your applications to account for idempotency. This post saw two approaches with Entity Framework Core and ASP.NET Core to reduce the chances of processing the same request twice. The code here is a starting point, but anyone can adapt it to their specific needs and technology stack.
There are a few downsides to this approach that developers should consider:
- Our database engine is a potential bottleneck and might slow the overall experience down.
- We are passing additional data to the server, which can affect performance.
- Generating unique tokens at scale can become expensive and need to be managed.
A more straightforward approach for some folks might be to generate a placeholder for the resource and then use subsequent requests to hydrate and complete the entity. Examples of preemptively creating a resource include shopping carts or completing an online survey. This modified approach eliminates the need for multiple data storage mechanisms and unique constraints.
To download this solution, you can head over to my GitHub repository and try it out yourself.
So, how do you limit duplicate requests in your system? Is idempotency vital to you, or is it just a part of running your web application? Let me know in the comments.