跳到主要内容
版本:0.5.x

幂等中间件 / Idempotency

Sharkable 提供一个可选启用的中间件,让客户端可以安全地重试非幂等的 HTTP 请求(POST / PUT / PATCH / DELETE),避免重复执行。第一次响应被缓存,后续携带相同 Idempotency-Key 头的请求会重放该响应。

Redis 插件

多实例部署需要开箱即用的 Redis 后端:

dotnet add package Sharkable.Cache.Redis
builder.Services.AddSharkableRedis("localhost:6379");
builder.Services.AddShark(opt =>
{
opt.EnableIdempotency = true;
});

GitHub → Sharkable.Cache.Redis

配置

services.AddSharkableRedis("localhost:6379", opt =>
{
opt.IdempotencyKeyPrefix = "myapp:idempotency:";
opt.Database = 1;
});

快速开始

builder.Services.AddShark([typeof(Program).Assembly], opt =>
{
opt.EnableIdempotency = true;
});

var app = builder.Build();
app.UseShark();

客户端在不安全请求上发送 Idempotency-Key 头:

POST /api/payments
Idempotency-Key: 8c0a6f4e-9b2d-4f1a-b3c7-2e5d8a1f0b6c
Content-Type: application/json

{ "amount": 100, "userId": 42 }

用相同 key 重试会重放原始响应(响应头 X-Idempotent-Replayed: true)。用相同 key 但不同 payload 重试返回 422。并发同 key 请求返回 409 + Retry-After: 1

配置

opt.ConfigureIdempotency(o =>
{
// 已完成响应保留时长,默认 24 小时
o.Ttl = TimeSpan.FromHours(24);

// in-flight 占位的自动驱逐时间,默认 30 秒
// 防止进程中途崩溃导致死锁
o.InFlightTtl = TimeSpan.FromSeconds(30);

// key 最大长度,默认 255(IETF 草案上限)
o.MaxKeyLength = 255;

// 超过此大小的响应会被 500 拒绝且不缓存,默认 1 MiB
o.MaxResponseSize = 1_048_576;

// 触发中间件的方法集合
o.UnsafeMethods = new HashSet<HttpMethod>
{
HttpMethod.Post, HttpMethod.Put, HttpMethod.Patch, HttpMethod.Delete
};

// 头名称(一般无需修改)
o.HeaderName = "Idempotency-Key";
o.ReplayedHeaderName = "X-Idempotent-Replayed";

// Retry-After 响应头值(秒)。默认 1。
o.RetryAfterSeconds = 1;

// 哪些状态码应被缓存。默认:2xx-4xx(不含 429)。
o.ShouldCacheStatus = status => status >= 200 && status < 500 && status != 429;
});

行为

场景结果
没有 Idempotency-Key透传,业务照常执行
GET / HEAD 带 key透传,header 被忽略
第一次带 key 请求执行,缓存响应(24h)
重放:同 key + 同 body重放缓存的响应;X-Idempotent-Replayed: true
重放:同 key + 不同 body422 idempotency_key_conflict
并发同 key 请求(首个尚未完成)409 idempotency_in_progress + Retry-After: 1
第一次响应是 5xx缓存,重试会重新执行
第一次响应是 429缓存,客户端应遵守 Retry-After
第一次响应是 2xx / 3xx / 4xx(其他)缓存并重放
响应体 > 1 MiB500 idempotency_response_too_large;释放 in-flight
Key 格式非法(空、> 255 字符、含控制字符)400 invalid_idempotency_key

"不同 body" 的判定基于 method + "\n" + path + "\n" + body 的 SHA-256 指纹。第一次请求的指纹会随响应一起存储,每次重放都做比对。

客户端如何生成 Key

Idempotency-Key 始终由客户端在发送请求前生成,服务器从不颁发。推荐做法:

  • 在用户做出业务动作的瞬间("点击支付"按钮)生成 UUID(v4),是在发起 HTTP 调用时
  • 持久化到 UI 状态(React state、按钮状态等),保证重试时复用同一个值
  • 同一次业务操作的所有重试都使用同一个 key
  • 每次新的业务操作使用不同的 key,即使用户重复同一个动作
// React 示例
const handlePay = () => {
const idempotencyKey = useRef(crypto.randomUUID()).current; // 只生成一次
fetch('/api/payments', {
method: 'POST',
headers: {
'Idempotency-Key': idempotencyKey,
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
};

错误响应格式

所有幂等错误都遵循框架标准的 UnifiedResult<T> envelope。机器可读的错误 code 以 [code] 前缀嵌入 errorMessage

{
"statusCode": 409,
"data": null,
"errorMessage": "[idempotency_in_progress] An identical request is already in progress; retry after 1 second.",
"extra": null,
"timeStamp": 1750934400000
}

客户端可以按 statusCode 路由,并检查 errorMessage 里的方括号前缀来区分具体的失败模式。

分布式存储

IIdempotencyStore 接口支持 Redis、PostgreSQL 等 KV 存储作为幂等性后端。

默认:MemoryIdempotencyStore

MemoryIdempotencyStore 基于 IMemoryCache,适用于单实例部署。

通过工厂自定义存储

AddShark() 回调中接入 Redis:

builder.Services.AddShark(opt =>
{
opt.IdempotencyStoreFactory = sp =>
{
var multiplexer = sp.GetRequiredService<IConnectionMultiplexer>();
return new RedisIdempotencyStore(multiplexer);
};
opt.EnableIdempotency = true;
});

通过 NuGet 插件自定义存储

AddShark() 之前注册自定义实现。TryAddSingleton 模式确保你的实现优先:

services.AddSingleton<IIdempotencyStore, MyCustomStore>();
builder.Services.AddShark(opt =>
{
opt.EnableIdempotency = true;
});

优先级

方式优先级
opt.IdempotencyStoreFactory最高(显式设置)
services.AddSingleton<IIdempotencyStore, T>()AddShark 之前中(插件)
默认 MemoryIdempotencyStore最低(兜底)

AOT 支持

中间件是 AOT 安全的。无反射、无用户类型上的 Create() 工厂调用、无 dynamicSharkable.AotSample 在构建期端到端验证该特性。

已知限制

  • 不支持流式响应。 超过 1 MiB 的响应会被 500 拒绝且不缓存。
  • 请求体指纹。 请求体的指纹计算要求中间件运行时请求体可读。如果端点已经消费过请求体(例如没有上游 EnableBuffering[FromBody] 模型绑定),指纹会基于空字节计算,422 检查会失效。