Chaves de idempotência na prática: APIs seguras para novas tentativas
Use chaves de idempotência para evitar efeitos duplicados ao repetir um POST após timeout.

Nesta página
- O problema e o contexto
- Mergulho na arquitetura
- A convenção do header Idempotency-Key
- O algoritmo no servidor
- Implementação na prática
- O contrato do store
- O store em SQLite
- O middleware
- O efeito colateral e o endpoint
- Provando com testes
- Checagem de realidade em produção
- Coloque a chave e o efeito colateral na mesma transação
- Chamadas downstream também precisam de idempotência
- Reivindicações presas em andamento
- Coisas menores que mordem
Um cliente toca em "Pagar". Sua API recebe POST /payments, chama o provedor de pagamento, grava o resultado e começa a devolver um 201 Created. Em algum ponto entre o seu servidor e o celular, a conexão cai. O app vê um timeout, e a política de retry dele faz exatamente aquilo para que foi construída: envia a requisição de novo. Sua API, sem fazer ideia de que é o mesmo pagamento, cobra o cartão pela segunda vez.
Nada nessa história é um bug quando olhado isoladamente. O bug mora no espaço entre as peças, e a correção padrão é uma idempotency key. Este post mostra a convenção do header, o algoritmo no servidor (incluindo a parte de concorrência, que é fácil de errar) e uma implementação funcional em ASP.NET Core no .NET 10, com SQLite e testes em xUnit. O Júnior Inocente também está aqui.
O problema e o contexto
Métodos HTTP têm semântica. GET, HEAD, PUT e DELETE são definidos como idempotentes: enviar a mesma requisição duas vezes deixa o servidor no mesmo estado que enviá-la uma vez. POST não faz essa promessa. Todo POST pode criar algo novo, que é exatamente o que você quer para "criar um pagamento" e exatamente o que dói quando a requisição se repete.
E requisições se repetem o tempo todo, muitas vezes por código que está se comportando corretamente:
- Timeouts no cliente com sucesso no servidor. Um timeout significa que o cliente parou de esperar, não que o servidor parou de trabalhar. A cobrança pode ter passado um milissegundo depois que o cliente desistiu.
- Políticas de retry no cliente. Bibliotecas de resiliência repetem falhas transitórias por design. Em Building a Resilient .NET API with Polly a recomendação era desligar retries em métodos não seguros, a menos que o servidor suporte idempotency keys. Este post é esse suporte do lado do servidor.
- Intermediários. Load balancers, API gateways e service meshes podem ser configurados para repetir requisições upstream em erros de conexão e, dependendo da configuração, podem não tratar POST de forma diferente de GET.
- Pessoas. Clique duplo, refresh em cima de um spinner, um botão "tentar de novo" num app mobile com conexão instável.
- Reentrega de mensagens. Filas com entrega at-least-once vão entregar a mesma mensagem a um consumidor mais de uma vez.
Isso troca a cobrança dupla por outro problema: ninguém sabe se o pagamento aconteceu. O usuário vê um erro e toca em "Pagar" de novo (um retry manual, mesmo resultado), ou desiste enquanto a cobrança passou em silêncio e o pedido nunca é enviado. O problema real não é o retry, é o servidor não conseguir distinguir um retry de uma requisição nova. Dê a ele um jeito de fazer isso e os retries ficam seguros, venham eles do Polly, de um gateway ou de um dedão.
Mergulho na arquitetura
A convenção do header Idempotency-Key
A ideia é simples. O cliente gera um valor único para cada operação de negócio (não para cada tentativa), envia esse valor num header Idempotency-Key e reutiliza o mesmo valor em todo retry daquela operação. O servidor lembra quais chaves já processou e o que respondeu.
POST /payments HTTP/1.1
Host: api.example.com
Content-Type: application/json
Idempotency-Key: "8e03978e-40d5-43e8-bc93-6894a57f9324"
{"amount": 100.00, "currency": "BRL"}APIs de pagamento como a da Stripe popularizaram esse padrão, e o grupo de trabalho HTTPAPI da IETF vem padronizando-o num draft chamado "The Idempotency-Key HTTP Header Field". O draft define o header como uma string de structured field (daí as aspas acima), recomenda um identificador aleatório como um UUID e descreve os casos de erro: chave ausente num endpoint que a exige recebe 400, reutilizar uma chave com payload diferente recebe 422, e um retry que chega enquanto a requisição original ainda está em processamento recebe 409. A implementação abaixo segue essa orientação.
No momento em que escrevo, a especificação do Idempotency-Key ainda é um Internet-Draft, não uma RFC. Confira a versão mais recente antes de tratar qualquer detalhe como definitivo e documente a sua própria política (endpoints que exigem a chave, formato, expiração) para quem consome a API.
O algoritmo no servidor
Para toda requisição a um endpoint que exige chave, o servidor faz o seguinte:
request (scope, key, body)
|
v
fingerprint = SHA-256(method, path, query, body)
|
v
try to INSERT (scope, key, fingerprint, state = in_progress) <- atomic, unique (scope, key)
|
+-- inserted --------> run handler --> 2xx/4xx: store response, state = completed
| --> 5xx/exception: delete the row (key can be retried)
|
+-- row exists, other fingerprint --> 422 Unprocessable Content
+-- row exists, in_progress ---------> 409 Conflict + Retry-After
+-- row exists, completed -----------> replay stored status, headers and bodyQuatro detalhes decidem se isso funciona ou não.
O fingerprint. A chave sozinha diz "esta é a mesma operação". O fingerprint confere essa afirmação. É um hash do método, do path, da query string e do body. Se um cliente reutiliza uma chave com um body diferente, isso é bug do cliente, e o servidor deve dizer isso em alto e bom som com um 422 em vez de tentar adivinhar.
Imagine um cliente que, sem querer, reutiliza uma única chave para todos os pagamentos de uma sessão. Sem fingerprint, o segundo pagamento, de R$ 250, recebe o "201 Created" guardado do primeiro, de R$ 100. O cliente acha que pagou, o servidor nunca cobrou, e a divergência aparece semanas depois na conciliação. O fingerprint transforma um bug de dados silencioso num erro imediato e óbvio.
A reivindicação atômica. Duas cópias da mesma requisição podem chegar ao mesmo tempo (um retry do gateway disputando com a original, duas instâncias atrás de um load balancer). Se as duas checam "essa chave existe?" antes de qualquer uma inserir, as duas não encontram nada e as duas cobram. A reivindicação precisa ser uma única operação atômica: um insert protegido por uma constraint única em (scope, key). Exatamente um insert vence; todos os outros descobrem que a chave já tem dono.
Fica fácil de ler e falha exatamente nas condições para as quais a idempotência existe. Entre o seu SELECT e o seu INSERT, outra requisição pode rodar o próprio SELECT e também não encontrar nada. Checar e depois agir é uma race condition, a menos que a checagem e a ação sejam um único comando ou um único lock. Deixe o banco garantir a unicidade e trate "o insert alterou zero linhas" como a resposta.
O que é guardado. Quem vence marca a chave como in_progress, executa o handler e depois registra o status code final, os headers relevantes (no mínimo Content-Type e Location) e o body. Resultados determinísticos são guardados: um 201 e também um 400 por valor inválido, já que repetir uma requisição inválida vai produzir o mesmo 400. Erros de servidor são diferentes. Um 5xx ou uma exceção não tratada muitas vezes significa que algo transitório falhou, e guardar isso tornaria a chave inútil para sempre: todo retry devolveria a falha. Então, em caso de 5xx, a reivindicação é liberada e o cliente pode tentar de novo com a mesma chave.
Essa escolha tem uma pré-condição: liberar a chave só é seguro se a tentativa que falhou não deixou um efeito colateral para trás. Se a cobrança deu certo e a gravação no banco logo depois quebrou, liberar a chave abre a porta para uma segunda cobrança. Guarde essa ideia; ela é o assunto principal da seção de produção.
Escopo e expiração. Chaves são únicas por cliente, não globalmente. Duas contas podem gerar a mesma chave (geradores com bug, fixtures de teste copiadas e coladas), e elas não podem colidir. Pior: um espaço de chaves global deixa um tenant receber a resposta guardada de outro. O escopo precisa vir da identidade autenticada.
Nunca deixe o cliente escolher o escopo. Se o escopo vem de um header que quem chama controla, qualquer um que adivinhe ou observe uma chave consegue receber a resposta guardada de outra conta, incluindo o body. Derive o escopo do principal autenticado (usuário, client da API, tenant) no servidor.
As chaves também precisam de um tempo de vida, e aqui o erro perigoso é expirar cedo demais. Uma chave precisa sobreviver à janela mais longa em que um retry daquela operação ainda pode chegar: orçamentos de retry dos clientes, filas offline de apps mobile, um job que retoma depois de um deploy. Se a chave expira em cinco minutos e o retry chega em dez, o servidor trata o retry como um pagamento novo. Uma escolha comum é 24 horas, que custa um pouco de armazenamento e compra bastante segurança. Seja qual for a escolha, publique-a.
Implementação na prática
Aqui está tudo no .NET 10: um middleware que aplica o algoritmo, uma abstração de store, uma implementação em SQLite cuja primary key faz a reivindicação atômica, um endpoint de pagamentos e testes em xUnit.
dotnet new web -n Payments.Api
dotnet new xunit -n Payments.Api.Tests
dotnet add Payments.Api package Microsoft.Data.Sqlite --version 10.0.12
dotnet add Payments.Api.Tests reference Payments.Api/Payments.Api.csproj
dotnet add Payments.Api.Tests package Microsoft.AspNetCore.Mvc.Testing --version 10.0.12Apague o UnitTest1.cs do template; os testes vêm mais adiante.
O contrato do store
using System;
using System.Threading;
using System.Threading.Tasks;
namespace Payments.Api.Idempotency;
public enum ClaimOutcome
{
Claimed,
InProgress,
Completed,
FingerprintMismatch
}
public sealed record StoredResponse(int StatusCode, string? ContentType, string? Location, byte[] Body);
public sealed record ClaimResult(ClaimOutcome Outcome, StoredResponse? Response = null);
public interface IIdempotencyStore
{
Task<ClaimResult> TryClaimAsync(string scope, string key, string fingerprint, TimeSpan ttl, CancellationToken ct);
Task CompleteAsync(string scope, string key, StoredResponse response, CancellationToken ct);
Task ReleaseAsync(string scope, string key, CancellationToken ct);
}Três operações mapeiam direto para o fluxo: reivindicar, concluir, liberar. O middleware nunca vê SQL, então o store por trás pode mudar depois.
O store em SQLite
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Data.Sqlite;
namespace Payments.Api.Idempotency;
public sealed class SqliteIdempotencyStore : IIdempotencyStore
{
private readonly string _connectionString;
private readonly TimeProvider _time;
public SqliteIdempotencyStore(string connectionString, TimeProvider time)
{
_connectionString = connectionString;
_time = time;
using var connection = new SqliteConnection(_connectionString);
connection.Open();
using var command = connection.CreateCommand();
command.CommandText = """
PRAGMA journal_mode = WAL;
CREATE TABLE IF NOT EXISTS idempotency_keys (
scope TEXT NOT NULL,
key TEXT NOT NULL,
fingerprint TEXT NOT NULL,
state TEXT NOT NULL CHECK (state IN ('in_progress', 'completed')),
status_code INTEGER NULL,
content_type TEXT NULL,
location TEXT NULL,
body BLOB NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
PRIMARY KEY (scope, key)
);
""";
command.ExecuteNonQuery();
}
public async Task<ClaimResult> TryClaimAsync(string scope, string key, string fingerprint, TimeSpan ttl, CancellationToken ct)
{
var now = _time.GetUtcNow();
await using var connection = await OpenAsync(ct);
// A primary key é o lock: de N inserts concorrentes, exatamente um altera uma linha.
// Uma linha expirada é assumida no mesmo comando, então não há corrida entre delete e insert.
await using (var claim = connection.CreateCommand())
{
claim.CommandText = """
INSERT INTO idempotency_keys (scope, key, fingerprint, state, created_at, expires_at)
VALUES ($scope, $key, $fingerprint, 'in_progress', $now, $expires)
ON CONFLICT (scope, key) DO UPDATE SET
fingerprint = excluded.fingerprint,
state = 'in_progress',
status_code = NULL,
content_type = NULL,
location = NULL,
body = NULL,
created_at = excluded.created_at,
expires_at = excluded.expires_at
WHERE idempotency_keys.expires_at <= excluded.created_at;
""";
claim.Parameters.AddWithValue("$scope", scope);
claim.Parameters.AddWithValue("$key", key);
claim.Parameters.AddWithValue("$fingerprint", fingerprint);
claim.Parameters.AddWithValue("$now", now.ToUnixTimeMilliseconds());
claim.Parameters.AddWithValue("$expires", now.Add(ttl).ToUnixTimeMilliseconds());
if (await claim.ExecuteNonQueryAsync(ct) == 1)
return new ClaimResult(ClaimOutcome.Claimed);
}
await using var select = connection.CreateCommand();
select.CommandText = """
SELECT fingerprint, state, status_code, content_type, location, body
FROM idempotency_keys
WHERE scope = $scope AND key = $key;
""";
select.Parameters.AddWithValue("$scope", scope);
select.Parameters.AddWithValue("$key", key);
await using var reader = await select.ExecuteReaderAsync(ct);
if (!await reader.ReadAsync(ct))
return new ClaimResult(ClaimOutcome.InProgress); // liberada entre os nossos dois comandos: tente depois
if (reader.GetString(0) != fingerprint)
return new ClaimResult(ClaimOutcome.FingerprintMismatch);
if (reader.GetString(1) == "in_progress")
return new ClaimResult(ClaimOutcome.InProgress);
var response = new StoredResponse(
StatusCode: reader.GetInt32(2),
ContentType: reader.IsDBNull(3) ? null : reader.GetString(3),
Location: reader.IsDBNull(4) ? null : reader.GetString(4),
Body: reader.IsDBNull(5) ? [] : (byte[])reader.GetValue(5));
return new ClaimResult(ClaimOutcome.Completed, response);
}
public async Task CompleteAsync(string scope, string key, StoredResponse response, CancellationToken ct)
{
await using var connection = await OpenAsync(ct);
await using var command = connection.CreateCommand();
command.CommandText = """
UPDATE idempotency_keys
SET state = 'completed', status_code = $status, content_type = $contentType, location = $location, body = $body
WHERE scope = $scope AND key = $key AND state = 'in_progress';
""";
command.Parameters.AddWithValue("$status", response.StatusCode);
command.Parameters.AddWithValue("$contentType", (object?)response.ContentType ?? DBNull.Value);
command.Parameters.AddWithValue("$location", (object?)response.Location ?? DBNull.Value);
command.Parameters.AddWithValue("$body", response.Body);
command.Parameters.AddWithValue("$scope", scope);
command.Parameters.AddWithValue("$key", key);
await command.ExecuteNonQueryAsync(ct);
}
public async Task ReleaseAsync(string scope, string key, CancellationToken ct)
{
await using var connection = await OpenAsync(ct);
await using var command = connection.CreateCommand();
command.CommandText = "DELETE FROM idempotency_keys WHERE scope = $scope AND key = $key AND state = 'in_progress';";
command.Parameters.AddWithValue("$scope", scope);
command.Parameters.AddWithValue("$key", key);
await command.ExecuteNonQueryAsync(ct);
}
private async Task<SqliteConnection> OpenAsync(CancellationToken ct)
{
var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync(ct);
return connection;
}
}A parte interessante é o upsert. INSERT ... ON CONFLICT DO UPDATE ... WHERE é um único comando atômico: ele insere uma linha nova ou assume uma linha expirada e, fora isso, não altera nada. Uma linha alterada significa "a chave é sua"; zero significa que ela é de outra requisição, e o SELECT seguinte diz se essa outra usou um body diferente, ainda está rodando ou já terminou. O Microsoft.Data.Sqlite faz retry quando o banco está ocupado até estourar o timeout do comando, então escritores concorrentes esperam em vez de falhar.
O middleware
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
namespace Payments.Api.Idempotency;
public sealed class RequireIdempotencyKeyMetadata;
public sealed class IdempotencyOptions
{
public TimeSpan KeyTtl { get; set; } = TimeSpan.FromHours(24);
public int MaxKeyLength { get; set; } = 255;
// Só para a demo: em produção, derive o escopo do principal autenticado, nunca de um header que o cliente controla.
public Func<HttpContext, string?> ResolveScope { get; set; } =
context => context.Request.Headers["X-Account-Id"].ToString() is { Length: > 0 } account ? account : null;
}
public sealed class IdempotencyMiddleware(RequestDelegate next, IIdempotencyStore store, IOptions<IdempotencyOptions> options)
{
public const string HeaderName = "Idempotency-Key";
internal const string ItemKey = "idempotency.scoped-key";
public async Task InvokeAsync(HttpContext context)
{
if (context.GetEndpoint()?.Metadata.GetMetadata<RequireIdempotencyKeyMetadata>() is null)
{
await next(context);
return;
}
var settings = options.Value;
// O draft define o valor como uma string de structured field ("abc"), mas muitos clientes enviam sem aspas.
var key = context.Request.Headers[HeaderName].ToString().Trim().Trim('"');
if (key.Length == 0 || key.Length > settings.MaxKeyLength)
{
await ProblemAsync(context, StatusCodes.Status400BadRequest, "This endpoint requires a valid Idempotency-Key header.");
return;
}
var scope = settings.ResolveScope(context);
if (scope is null)
{
await ProblemAsync(context, StatusCodes.Status401Unauthorized, "Unknown account.");
return;
}
var fingerprint = await FingerprintAsync(context.Request, context.RequestAborted);
var claim = await store.TryClaimAsync(scope, key, fingerprint, settings.KeyTtl, context.RequestAborted);
switch (claim.Outcome)
{
case ClaimOutcome.FingerprintMismatch:
await ProblemAsync(context, StatusCodes.Status422UnprocessableEntity,
"This Idempotency-Key was already used with a different request.");
return;
case ClaimOutcome.InProgress:
context.Response.Headers.RetryAfter = "1";
await ProblemAsync(context, StatusCodes.Status409Conflict,
"A request with this Idempotency-Key is still being processed.");
return;
case ClaimOutcome.Completed:
await ReplayAsync(context, claim.Response!);
return;
}
context.Items[ItemKey] = $"{scope}:{key}";
await ExecuteAndRecordAsync(context, scope, key);
}
private async Task ExecuteAndRecordAsync(HttpContext context, string scope, string key)
{
var originalBody = context.Response.Body;
using var buffer = new MemoryStream();
context.Response.Body = buffer;
try
{
await next(context);
}
catch
{
// Não há um resultado confiável para devolver, então libera a chave e deixa um retry executar o handler de novo.
await store.ReleaseAsync(scope, key, CancellationToken.None);
throw;
}
finally
{
context.Response.Body = originalBody;
}
var response = new StoredResponse(
context.Response.StatusCode,
context.Response.ContentType,
context.Response.Headers.Location.ToString() is { Length: > 0 } location ? location : null,
buffer.ToArray());
// CancellationToken.None: o efeito colateral já aconteceu, então registre mesmo que o cliente tenha desconectado.
if (response.StatusCode >= 500)
await store.ReleaseAsync(scope, key, CancellationToken.None);
else
await store.CompleteAsync(scope, key, response, CancellationToken.None);
await originalBody.WriteAsync(response.Body);
}
private static async Task ReplayAsync(HttpContext context, StoredResponse stored)
{
context.Response.StatusCode = stored.StatusCode;
context.Response.ContentType = stored.ContentType;
if (stored.Location is not null)
context.Response.Headers.Location = stored.Location;
context.Response.Headers["Idempotent-Replayed"] = "true";
await context.Response.Body.WriteAsync(stored.Body, context.RequestAborted);
}
private static async Task<string> FingerprintAsync(HttpRequest request, CancellationToken ct)
{
request.EnableBuffering();
using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
hash.AppendData(Encoding.UTF8.GetBytes($"{request.Method} {request.Path}{request.QueryString}\n"));
var chunk = new byte[8192];
int read;
while ((read = await request.Body.ReadAsync(chunk, ct)) > 0)
hash.AppendData(chunk, 0, read);
request.Body.Position = 0;
return Convert.ToHexString(hash.GetHashAndReset());
}
private static Task ProblemAsync(HttpContext context, int statusCode, string detail) =>
Results.Problem(detail: detail, statusCode: statusCode).ExecuteAsync(context);
}
public static class IdempotencyExtensions
{
public static IApplicationBuilder UseIdempotency(this IApplicationBuilder app) =>
app.UseMiddleware<IdempotencyMiddleware>();
public static TBuilder RequireIdempotencyKey<TBuilder>(this TBuilder builder) where TBuilder : IEndpointConventionBuilder =>
builder.WithMetadata(new RequireIdempotencyKeyMetadata());
public static string? GetIdempotencyKey(this HttpContext context) =>
context.Items[IdempotencyMiddleware.ItemKey] as string;
}Por que um middleware e não um endpoint filter? Um filter enxerga o IResult do handler, mas o replay precisa dos bytes exatos que saíram. O middleware troca o body da resposta por um MemoryStream, deixa o endpoint escrever nele, guarda o resultado e depois copia para o stream real. Ele só age em endpoints marcados com RequireIdempotencyKey(), então o resto da API não paga nada por isso. O EnableBuffering permite gerar o hash do body e rebobiná-lo para o model binding.
Registre o resultado com CancellationToken.None, não com RequestAborted. O cliente desconectar é justamente o cenário que dispara um retry. Se o registro for cancelado junto com a requisição, a cobrança acontece mas a chave fica em in_progress, e o retry recebe 409 até a chave expirar, em vez da resposta original.
O efeito colateral e o endpoint
using System;
using System.Threading;
using System.Threading.Tasks;
namespace Payments.Api.Payments;
public sealed record ChargeRequest(decimal Amount, string Currency);
public sealed record Payment(Guid Id, decimal Amount, string Currency, string Status);
public interface IPaymentGateway
{
// Um client real do provedor envia idempotencyKey na sua própria requisição de saída.
Task<Payment> ChargeAsync(ChargeRequest request, string idempotencyKey, CancellationToken ct);
}
// Conta toda chamada, para os testes provarem quantas vezes o efeito colateral realmente rodou.
public sealed class FakePaymentGateway(TimeSpan latency) : IPaymentGateway
{
private int _charges;
public int Charges => Volatile.Read(ref _charges);
public async Task<Payment> ChargeAsync(ChargeRequest request, string idempotencyKey, CancellationToken ct)
{
Interlocked.Increment(ref _charges);
await Task.Delay(latency, ct);
return new Payment(Guid.NewGuid(), request.Amount, request.Currency, "succeeded");
}
}using System;
using System.IO;
using System.Threading;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Payments.Api.Idempotency;
using Payments.Api.Payments;
var builder = WebApplication.CreateBuilder(args);
var dbPath = builder.Configuration["Idempotency:DatabasePath"] ?? Path.Combine(AppContext.BaseDirectory, "idempotency.db");
builder.Services.AddProblemDetails();
builder.Services.AddOptions<IdempotencyOptions>();
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddSingleton<IIdempotencyStore>(sp =>
new SqliteIdempotencyStore($"Data Source={dbPath}", sp.GetRequiredService<TimeProvider>()));
builder.Services.AddSingleton<IPaymentGateway>(new FakePaymentGateway(TimeSpan.FromMilliseconds(50)));
var app = builder.Build();
app.UseRouting();
app.UseIdempotency();
app.MapPost("/payments", async (ChargeRequest request, HttpContext context, IPaymentGateway gateway, CancellationToken ct) =>
{
if (request.Amount <= 0)
return Results.Problem(detail: "Amount must be positive.", statusCode: StatusCodes.Status400BadRequest);
// Repassa uma chave derivada da nossa, para que um retry que chegue ao provedor também seja deduplicado lá.
var payment = await gateway.ChargeAsync(request, $"charge:{context.GetIdempotencyKey()}", ct);
return Results.Created($"/payments/{payment.Id}", payment);
})
.RequireIdempotencyKey();
app.Run();
public partial class Program;O UseRouting() vem antes do UseIdempotency() para que o middleware consiga ler os metadados do endpoint que casou com a rota. O handler não sabe nada de deduplicação além de repassar uma chave derivada.
Provando com testes
O WebApplicationFactory roda o pipeline real em memória. Cada teste recebe o próprio arquivo SQLite e um gateway fake com 200 ms de latência, tempo suficiente para requisições concorrentes se sobreporem.
using System;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.DependencyInjection;
using Payments.Api.Idempotency;
using Payments.Api.Payments;
using Xunit;
namespace Payments.Api.Tests;
public sealed class IdempotencyTests : IDisposable
{
private readonly string _dbPath = Path.Combine(Path.GetTempPath(), $"idempotency-{Guid.NewGuid():N}.db");
private readonly FakePaymentGateway _gateway = new(TimeSpan.FromMilliseconds(200));
private readonly WebApplicationFactory<Program> _factory;
public IdempotencyTests()
{
var store = new SqliteIdempotencyStore($"Data Source={_dbPath};Pooling=False", TimeProvider.System);
_factory = new WebApplicationFactory<Program>().WithWebHostBuilder(web =>
web.ConfigureTestServices(services =>
{
services.AddSingleton<IIdempotencyStore>(store);
services.AddSingleton<IPaymentGateway>(_gateway);
}));
}
[Fact]
public async Task Replay_returns_the_stored_response_without_charging_again()
{
var client = _factory.CreateClient();
using var first = await client.SendAsync(Charge("key-1", 100m));
using var second = await client.SendAsync(Charge("key-1", 100m));
Assert.Equal(HttpStatusCode.Created, first.StatusCode);
Assert.Equal(HttpStatusCode.Created, second.StatusCode);
Assert.Equal(await first.Content.ReadAsStringAsync(), await second.Content.ReadAsStringAsync());
Assert.Equal(first.Headers.Location, second.Headers.Location);
Assert.True(second.Headers.Contains("Idempotent-Replayed"));
Assert.Equal(1, _gateway.Charges);
}
[Fact]
public async Task Same_key_with_a_different_body_is_rejected()
{
var client = _factory.CreateClient();
using var first = await client.SendAsync(Charge("key-2", 100m));
using var second = await client.SendAsync(Charge("key-2", 250m));
Assert.Equal(HttpStatusCode.Created, first.StatusCode);
Assert.Equal(HttpStatusCode.UnprocessableEntity, second.StatusCode);
Assert.Equal(1, _gateway.Charges);
}
[Fact]
public async Task Concurrent_duplicates_charge_exactly_once()
{
var client = _factory.CreateClient();
var responses = await Task.WhenAll(
Enumerable.Range(0, 10).Select(_ => client.SendAsync(Charge("key-3", 100m))));
var statuses = responses.Select(r => r.StatusCode).ToList();
Assert.Equal(1, _gateway.Charges);
Assert.Contains(HttpStatusCode.Created, statuses);
Assert.All(statuses, s => Assert.True(s is HttpStatusCode.Created or HttpStatusCode.Conflict, $"unexpected {s}"));
}
[Fact]
public async Task Keys_are_scoped_per_account()
{
var client = _factory.CreateClient();
using var first = await client.SendAsync(Charge("key-4", 100m, account: "acct_a"));
using var second = await client.SendAsync(Charge("key-4", 100m, account: "acct_b"));
Assert.Equal(HttpStatusCode.Created, first.StatusCode);
Assert.Equal(HttpStatusCode.Created, second.StatusCode);
Assert.Equal(2, _gateway.Charges);
}
[Fact]
public async Task Missing_key_is_rejected_before_any_charge()
{
var client = _factory.CreateClient();
using var request = new HttpRequestMessage(HttpMethod.Post, "/payments")
{
Content = JsonContent.Create(new ChargeRequest(100m, "BRL"))
};
request.Headers.Add("X-Account-Id", "acct_a");
using var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Equal(0, _gateway.Charges);
}
private static HttpRequestMessage Charge(string key, decimal amount, string account = "acct_a")
{
var request = new HttpRequestMessage(HttpMethod.Post, "/payments")
{
Content = JsonContent.Create(new ChargeRequest(amount, "BRL"))
};
request.Headers.Add(IdempotencyMiddleware.HeaderName, key);
request.Headers.Add("X-Account-Id", account);
return request;
}
public void Dispose()
{
_factory.Dispose();
SqliteConnection.ClearAllPools();
foreach (var suffix in new[] { "", "-wal", "-shm" })
File.Delete(_dbPath + suffix);
}
}dotnet test Payments.Api.TestsOs cinco testes passam. O de concorrência é o que vale reler: dez requisições idênticas disparam ao mesmo tempo, o gateway é chamado exatamente uma vez, e todas as outras requisições recebem ou um 409 (ainda em processamento) ou o 201 devolvido do store.
Checagem de realidade em produção
O middleware é uma base sólida. Estas são as lacunas que importam em escala.
Coloque a chave e o efeito colateral na mesma transação
O middleware guarda a chave num lugar e o handler grava o pagamento em outro. Se o processo morrer entre o commit do pagamento e o CompleteAsync, a chave fica em in_progress enquanto o pagamento existe. Quando o efeito colateral é uma gravação no seu próprio banco, o desenho mais forte leva a reivindicação para a mesma transação da gravação de negócio, de modo que as duas fazem commit ou nenhuma faz:
BEGIN;
INSERT INTO idempotency_keys (scope, key, fingerprint, state, created_at, expires_at)
VALUES (@scope, @key, @fingerprint, 'in_progress', @now, @expires); -- violação de unicidade: pare, é duplicata
INSERT INTO payments (id, account_id, amount, currency) VALUES (@id, @scope, @amount, @currency);
UPDATE idempotency_keys
SET state = 'completed', status_code = 201, body = @body
WHERE scope = @scope AND key = @key;
COMMIT;Na prática, isso significa que o handler (ou uma unit of work em volta dele) é dono da reivindicação, em vez de um middleware genérico. Liberar a chave em caso de falha também fica seguro de graça, porque um rollback desfaz a linha do pagamento junto.
Chamadas downstream também precisam de idempotência
Um provedor de pagamento não está na sua transação, e nenhuma esperteza local resolve isso. A saída é empurrar o problema para baixo: envie uma chave derivada da sua (charge:{scope}:{key} no exemplo) na chamada ao provedor. Agora um retry depois de um crash, venha ele do seu código, do seu pipeline do Polly ou de uma mensagem reentregue, chega a um provedor que também deduplica. É isso que torna defensável "liberar a chave em 5xx" quando o efeito colateral é remoto: mesmo que a cobrança tenha acontecido, o retry com a mesma chave derivada recebe a cobrança original de volta em vez de uma nova.
Reivindicações presas em andamento
Se um processo quebra no meio de uma requisição, a linha dele fica em in_progress até expirar, e todo retry recebe 409. Com um TTL de 24 horas, é tempo demais. Adicione um lease (locked_until) que uma nova requisição pode assumir depois que ele vence, e deixe-o bem mais longo que a sua requisição legítima mais lenta. Retomar um lease enquanto a requisição original ainda está de fato rodando é o risco que você aceita, e a chave downstream é o que limita o estrago.
Mais rápido, e errado assim que você sobe uma segunda instância. O retry que mais importa é justamente o que cai numa instância diferente da original, porque o load balancer espalha as conexões. E o dicionário some a cada deploy ou restart, que é quando os retries disparam. O store precisa ser compartilhado e durável: o seu banco relacional principal (ideal quando você quer a reivindicação transacional), ou Redis com SET key value NX PX ttl para a reivindicação, se você aceita os trade-offs de durabilidade dele. O SQLite aqui é um substituto que deixa a reivindicação atômica fácil de ver e de testar.
Coisas menores que mordem
- Fingerprints no nível de bytes. Gerar hash dos bytes crus significa que um retry que serializa o JSON de novo com outro espaçamento ou outra ordem de propriedades é tratado como requisição diferente. Clientes devem reenviar exatamente o mesmo body; se você não pode garantir isso, gere o fingerprint de uma forma canônica do payload já parseado.
- Limpeza. Linhas expiradas não se apagam sozinhas. Rode um job periódico que remova as linhas com
expires_atvencido e limite o tamanho do body guardado. - Fidelidade do replay. Guarde os headers de que os clientes dependem (
Location,Content-Type, talvez um ETag), não os que são por requisição, como trace ids. - Observabilidade. Conte reivindicações, replays, 409s e 422s. Um pico de 422s normalmente significa que algum cliente está reutilizando chaves.
Idempotency keys transformam "meu pagamento passou?" numa pergunta que o servidor responde do mesmo jeito toda vez. O cliente é dono de uma chave por operação; o servidor a reivindica de forma atômica, confere o fingerprint, guarda a resposta final e a mantém por mais tempo do que qualquer um conseguiria repetir a requisição. Some a isso uma chave derivada na chamada ao provedor, e uma conexão que cai vira um não evento em vez de um chamado de estorno.
Conteúdos relacionados
- BlogMensageria em tempo real com Azure Service Bus e C#: guia completoCrie um worker C# de Service Bus idempotente, com sessions, liquidação explícita e recuperação de dead-letter.
- BlogConstruindo uma API .NET resiliente com Polly: retentativas, circuit breakers e timeoutsLimite retries com timeouts e circuit breakers para uma dependência falha não derrubar sua API.
- BlogO plano Strangler Fig: migrando um monólito para a nuvem sem Big BangMigre um monólito uma rota por vez, com dono claro para os dados e rollback por fatia.
Comentários
Dúvidas, correções ou sua própria visão são todas bem-vindas. Entre com o GitHub para participar.