feat(api/offline): idempotency-key middleware for safe write retries
Backend half of offline Phase 1. Lets the offline outbox replay a write after a
lost response without executing it twice (e.g. an order whose POST reached the
server but whose reply never came back).
- IdempotencyRecord entity + table (unique index on (Scope, Key)); migration
AddIdempotencyRecords. Standalone POCO — no tenant/soft-delete filters.
- IdempotencyMiddleware (after TenantMiddleware, before plan-limit/controllers):
opt-in via `Idempotency-Key` header on POST/PUT/PATCH/DELETE.
* Completed key → replays stored status+body with `Idempotent-Replay: true`.
* In-progress key → 409 IDEMPOTENCY_IN_PROGRESS; the unique index serializes
racing first requests; stale (>60s) reservations are recovered after a crash.
* Only <500 responses are cached; 5xx is released so the client can retry.
Bookkeeping runs in isolated DI scopes so it never contaminates the controller's
unit of work. Keys are scoped per café — no cross-tenant collisions.
- 5 middleware tests (replay/execute-once, distinct key, pass-through, tenant
isolation, 5xx-not-cached). Full suite 86 passing.
Next in Phase 1: generalize the POS order queue into a generic client outbox that
sends these keys and remaps client→server ids.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
using Meezi.Core.Enums;
|
||||
|
||||
namespace Meezi.Core.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Records a client-supplied Idempotency-Key so a retried write (e.g. an order
|
||||
/// replayed from the offline outbox after a lost response) returns the original
|
||||
/// result instead of executing twice. Standalone POCO — deliberately not a
|
||||
/// TenantEntity, to avoid soft-delete/tenant query filters.
|
||||
/// </summary>
|
||||
public class IdempotencyRecord
|
||||
{
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString("N");
|
||||
|
||||
/// <summary>Tenant scope (CafeId), or "global" for non-tenant requests.</summary>
|
||||
public string Scope { get; set; } = "global";
|
||||
|
||||
/// <summary>The client-supplied Idempotency-Key header value.</summary>
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
public string Method { get; set; } = string.Empty;
|
||||
public string Path { get; set; } = string.Empty;
|
||||
|
||||
public IdempotencyStatus Status { get; set; } = IdempotencyStatus.InProgress;
|
||||
|
||||
public int ResponseStatusCode { get; set; }
|
||||
public string? ResponseBody { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime? CompletedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Meezi.Core.Enums;
|
||||
|
||||
public enum IdempotencyStatus
|
||||
{
|
||||
/// <summary>Reserved; the original request is still executing.</summary>
|
||||
InProgress = 0,
|
||||
/// <summary>Finished; the stored response is replayed on duplicate keys.</summary>
|
||||
Completed = 1
|
||||
}
|
||||
Reference in New Issue
Block a user