7

Preventing Duplicate Web Requests To ASP.NET Core

 3 years ago
source link: https://khalidabuhakmeh.com/preventing-duplicate-web-requests-to-aspnet-core
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client
June 8, 2021

Preventing Duplicate Web Requests To ASP.NET Core

Photo by Ralph Mayhew

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:

  1. In the OnGet method, we generate a unique token using Guid, but this could be any unique value. We will use the value in our HTML form.
  2. In our OnPost method, we attempt to save the IdempotentToken along with our Text value. If it is the first time we see the request, we will store it with no issues.
  3. If we have already stored the token, we will get a DbUpdateException with an inner exception of SqliteException. 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.

successful web request using current idempotent token

Looks good; what happens when we reuse a token?

unsuccessful web request when not idempotent token is present

Finally, what happens when we don’t include a token at all?

web request using current idempotent token

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.


Recommend

  • 11

    ASP.NET Core Pitfalls – AJAX Requests and XSRF When using Anti Cross Site Scripting Forgery (XSRF) protection in your application, which...

  • 9
    • zhuanlan.zhihu.com 3 years ago
    • Cache

    ASP.NET Core Web API 最佳实践指南

    ASP.NET Core Web API 最佳实践指南前文传送门:01 介绍当我们编写一个项目的时候,我们的主要目标是使它能如期运行,并尽可能地满足所有用户需求。但是,你难道不认为创建一个能正常工作的项目还不够吗?同时这个项目...

  • 11

    Four years ago I wrote about how to log request/response headers in ASP.NET Core. This functionality has now been

  • 4

    ASP.NET Core Web API 教程 本系列文章主要参考了《Ultimate ASP.NET Core 3 Web API》一书,我对原文进行了翻译,同时适当删减、修改了一部分内容。 对于某些概念和原理,原书和本文中都没有进行详细描述,如果一一详细介绍,内...

  • 11

    【ASP.NET Core】体验一下 Mini Web API 在上一篇水文中,老周给大伙伴们简单演示了通过...

  • 6
    • www.telerik.com 2 years ago
    • Cache

    ASP.NET Core for Beginners: Web APIs

    If you work with the .NET platform or are new to this area, you need to know about Web APIs—robust and secure applications that use the HTTP protocol to communicate between servers and clients and are in high demand in the market. Wha...

  • 7
    • blogs.msmvps.com 2 years ago
    • Cache

    Posting AJAX Requests to ASP.NET Core MVC

    Posting AJAX Requests to ASP.NET Core MVC Introduction In the past, I’ve had trouble doing something that is apparently simple: invoking a simple action method in a controller using AJAX. Although it is...

  • 6
    • fiyazhasan.me 2 years ago
    • Cache

    Versioning for ASP.NET Core Web API

    November 19, 2020 Versioning for ASP.NET Core Web API .NET Few days ago, me and my friends were developing an API using ASP.NET Core...

  • 9

  • 7

    Intro This is part 1 in a series where I will be covering various aspects of building a Web API using the newest version of ASP.NET Core. In this part we will look at building a basic API for a fictive online store. In the fol...

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK