Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
0359dea
fix(web): correct idempotent replay payload and serialize concurrent …
marcelo-maciel Jul 11, 2026
e669afc
fix(web): make idempotency replay actually engage (symmetric cache st…
marcelo-maciel Jul 11, 2026
8a8761a
fix(web): short-TTL, fail-open idempotency reservation
marcelo-maciel Jul 13, 2026
f7c8c32
fix(web): make the stored idempotent response outlive the request
marcelo-maciel Aug 12, 2026
52365f3
fix(web): scope the idempotency entry to the operation, not the tenan…
marcelo-maciel Aug 12, 2026
53ef779
fix(web): close the idempotency reservation's races
marcelo-maciel Aug 12, 2026
b18e541
fix(web): keep an idempotent handler alive past a client disconnect
marcelo-maciel Aug 12, 2026
08fcade
test(web): cover the idempotency branches no test could fail on
marcelo-maciel Aug 12, 2026
f3d66c5
test(identity): pin that the caller id claim survives bearer inbound …
marcelo-maciel Aug 12, 2026
5b33004
docs(agents): idempotency rule covers the abort-token, probe and key …
marcelo-maciel Aug 12, 2026
e6b0bb7
test(web): assert idempotency options through IStartupValidator, not …
marcelo-maciel Aug 12, 2026
75eb3b9
test(web): pin each idempotency options clause to its own failure mes…
marcelo-maciel Aug 12, 2026
430122a
fix(identity): drop idempotency from self-registration, and gate anon…
marcelo-maciel Aug 12, 2026
b277001
test(multitenancy): carry the response body into the theme status ass…
marcelo-maciel Aug 13, 2026
c204603
build(deps): pin SSH.NET to the patched 2026.0.0
marcelo-maciel Aug 13, 2026
7c2dcba
fix(web): keep request-derived text out of the idempotency warning
marcelo-maciel Aug 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .agents/rules/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Chained partitioned fixed-window limiter: **tenant → user → IP** (defaults 1

## Idempotency (`Web/Idempotency/`)

Opt-in per endpoint with **`.WithIdempotency()`**. Reads the `Idempotency-Key` header (max 128 chars, 24h TTL); replays return the cached response with `Idempotency-Replayed: true`. Cache key is tenant-scoped (`CacheKeys.IdempotencyEntry`). Put it on POSTs that must be replay-safe (e.g. CreateTenant).
Opt-in per endpoint with **`.WithIdempotency()`**. Reads the `Idempotency-Key` header (max 128 chars, `DefaultTtl` 24h); replays return the cached status, body and the allow-listed headers (`Location`, `ETag`) plus `Idempotency-Replayed: true`. Cache key is scoped to tenant **and operation** (method + route pattern) via `CacheKeys.IdempotencyEntry` — tenant alone lets one key reused on a second idempotent endpoint replay the first one's response, and anonymous endpoints (self-registration) all share the `"global"` tenant. Tenant comes from the **resolved** `IMultiTenantContextAccessor` (what the side effect is scoped to, incl. a root operator's target tenant), claim as fallback, `"global"` otherwise — never the raw `tenant` header, which is unvalidated when Finbuckle didn't resolve it. Probe and write share one `IDistributedCache` + key + serializer — asymmetry there makes replay silently never engage. Only **2xx** is stored: a failure isn't a record of a committed side effect, and caching it would lock the key out for the full TTL. The store happens **before** the body reaches the client and on `CancellationToken.None`, so a client that times out and retries replays instead of re-executing. Concurrent duplicates are serialized by an in-flight reservation (Redis `SET NX`, else in-process) under a `lock:` prefix — never a suffix on the entry key, which a caller key ending in it would collide with — on the short `ReservationTtl` (default 1m, must outlast the slowest handler; both branches expire on it). The cache is re-probed **after** the lock is taken (the original can settle in the probe→reserve window), release is a compare-and-delete on the reservation's own token, and a duplicate still in flight gets **409**. Reserve and release both fail open; a request that failed open holds no token and releases nothing. The **probe** fails open too — it runs on every request carrying a key, so a Redis blip must not 500 the endpoint for exactly those clients. The key also folds in the resolved **route values** (`PUT /tickets/1` ≠ `/tickets/2`) and the **caller** (`GetUserId()`, `"anon"` otherwise), so two users of one tenant reusing a low-entropy key never see each other's body. The handler runs with `HttpContext.RequestAborted` **detached** — a client hanging up after the side effect committed used to cancel the handler's next await, leaving nothing to store and letting the retry re-execute; the trade-off is that a disconnect no longer aborts an idempotent handler, so keep those handlers short and **never** put `.WithIdempotency()` on a streaming or large-file endpoint (the response is buffered to be captured). A handler that writes to `Response` itself is passed through untouched (`HasStarted`), the 409 carries `Retry-After: 1`, and `IdempotencyOptions` is validated on start. **Never on an `AllowAnonymous()` endpoint** — every unauthenticated caller resolves to the same `"anon"` caller, so one caller's low-entropy key replays another's response; `Integration.Tests/.../IdempotencyWiringTests` fails the build if one appears (`WithIdempotency()` attaches `IdempotentEndpointMetadata` so the endpoint map is inspectable). Put it on authenticated POSTs that must be replay-safe (e.g. CreateTenant).

## Quota enforcement (`Quota/`)

Expand Down
6 changes: 5 additions & 1 deletion src/BuildingBlocks/Caching/CacheKeys.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@ public static class Tags
/// <summary>Tag applied to every tenant theme entry.</summary>
public const string Themes = "themes";

/// <summary>Tag applied to every idempotency replay entry.</summary>
/// <summary>
/// Reserved for idempotency replay entries — not applied to them today. Those entries live in
/// <c>IDistributedCache</c>, which carries no tags, so a tag purge does not reach them; they
/// expire on their own TTL instead.
/// </summary>
public const string Idempotency = "idempotency";

/// <summary>Per-tenant tag — invalidates all entries scoped to a tenant.</summary>
Expand Down
19 changes: 12 additions & 7 deletions src/BuildingBlocks/Web/Idempotency/CachedIdempotentResponse.cs
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
using System.ComponentModel;

namespace FSH.Framework.Web.Idempotency;

/// <summary>
/// A cached HTTP response for idempotent replay.
/// </summary>
/// <remarks>
/// Marked <see cref="ImmutableObjectAttribute"/> + <c>sealed</c> so HybridCache can reuse the
/// in-process instance across requests without re-deserializing on every L1 hit.
/// </remarks>
[ImmutableObject(true)]
public sealed record CachedIdempotentResponse
{
public int StatusCode { get; init; }

public string? ContentType { get; init; }

public byte[] Body { get; init; } = [];

/// <summary>
/// Response headers replayed alongside the body. Only headers that carry meaning for the caller
/// are captured (see the filter's allow-list) — the host sets the transport ones itself, and
/// replaying a stale <c>Content-Length</c> or <c>Transfer-Encoding</c> would corrupt the response.
/// Defaults to empty so entries written before headers were captured still deserialize.
/// Iterate it, do not look a header up by name: deserialization replaces this instance with a
/// plain case-SENSITIVE dictionary, so the initializer's comparer only holds on the write path.
/// </summary>
public IReadOnlyDictionary<string, string> Headers { get; init; } = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
}
14 changes: 13 additions & 1 deletion src/BuildingBlocks/Web/Idempotency/Extensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,20 @@ public static IServiceCollection AddHeroIdempotency(this IServiceCollection serv
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);

// Validated at startup, like every other options block here. A misconfigured TTL fails
// SILENTLY otherwise: a zero or negative DefaultTtl throws inside the best-effort cache
// write, which logs a warning and moves on, so no response is ever stored and replay never
// engages — the exact silent failure this filter exists to have stopped having. A
// ReservationTtl of zero expires the in-flight lock the moment it is taken, so concurrent
// duplicates both run the handler.
services.AddOptions<IdempotencyOptions>()
.BindConfiguration(nameof(IdempotencyOptions));
.BindConfiguration(nameof(IdempotencyOptions))
.Validate(o => !string.IsNullOrWhiteSpace(o.HeaderName), "IdempotencyOptions.HeaderName is required.")
.Validate(o => o.DefaultTtl > TimeSpan.Zero, "IdempotencyOptions.DefaultTtl must be greater than zero.")
.Validate(o => o.ReservationTtl > TimeSpan.Zero, "IdempotencyOptions.ReservationTtl must be greater than zero.")
.Validate(o => o.ReservationTtl <= o.DefaultTtl, "IdempotencyOptions.ReservationTtl must not exceed DefaultTtl — the reservation only has to outlast the handler.")
.Validate(o => o.MaxKeyLength > 0, "IdempotencyOptions.MaxKeyLength must be greater than zero.")
.ValidateOnStart();

return services;
}
Expand Down
Loading
Loading