diff --git a/src/BuildingBlocks/Web/Idempotency/IdempotencyEndpointFilter.cs b/src/BuildingBlocks/Web/Idempotency/IdempotencyEndpointFilter.cs
index fa73b35c7c..f2172103a6 100644
--- a/src/BuildingBlocks/Web/Idempotency/IdempotencyEndpointFilter.cs
+++ b/src/BuildingBlocks/Web/Idempotency/IdempotencyEndpointFilter.cs
@@ -1,3 +1,4 @@
+using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
@@ -5,10 +6,10 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Caching.Distributed;
-using Microsoft.Extensions.Caching.Hybrid;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
+using StackExchange.Redis;
namespace FSH.Framework.Web.Idempotency;
@@ -18,16 +19,24 @@ namespace FSH.Framework.Web.Idempotency;
/// for subsequent requests with the same key.
///
///
-/// Uses directly for the probe read (bypassing
-/// 's factory-mandatory API) and
-/// for the write path so replays benefit from L1 and the regular tag invalidation story.
-/// Using HybridCache with DisableUnderlyingData as a "get-only probe" is a
-/// known anti-pattern tracked at dotnet/aspnetcore#57191.
+/// Uses for both the probe read and the write, on the same raw key
+/// and serializer, so the two are symmetric (a HybridCache write keys its L2 entries under its own
+/// scheme, which a raw-key probe never finds — replay then silently never engages).
+/// The handler result is executed into a buffer so the cached payload is the real wire body and
+/// status code (an Ok<T> /Created<T> wrapper would otherwise be serialized
+/// verbatim, and Response.StatusCode is still the default at filter time — the IResult sets
+/// it only when it executes). Concurrent duplicate keys are serialized by an atomic in-flight
+/// reservation (Redis SET NX when a multiplexer is registered, an in-process set otherwise).
///
public sealed class IdempotencyEndpointFilter : IEndpointFilter
{
private static readonly JsonSerializerOptions JsonOpts = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
+ // In-process reservation used when no Redis multiplexer is registered. Single-instance only —
+ // a multi-instance host in this stack already runs Redis (shared Data Protection key ring), so
+ // the Redis branch below covers every deployment where cross-instance duplicates are possible.
+ private static readonly ConcurrentDictionary InFlight = new(StringComparer.Ordinal);
+
public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
ArgumentNullException.ThrowIfNull(context);
@@ -49,70 +58,197 @@ public sealed class IdempotencyEndpointFilter : IEndpointFilter
}
var distributedCache = httpContext.RequestServices.GetRequiredService();
- var hybridCache = httpContext.RequestServices.GetRequiredService();
var logger = httpContext.RequestServices.GetRequiredService>();
// Include tenant context in cache key for isolation
var tenantId = httpContext.User.FindFirst("tenant")?.Value ?? "global";
var cacheKey = CacheKeys.IdempotencyEntry(tenantId, idempotencyKey);
- var tags = new[] { CacheKeys.Tags.Idempotency, CacheKeys.Tags.Tenant(tenantId) };
// Probe-only read via IDistributedCache (real GetAsync, null on miss — unlike HybridCache's
// factory). Bypasses L1: replays are rare vs first-calls, so L1 warmth has little value.
- var cachedBytes = await distributedCache.GetAsync(cacheKey, httpContext.RequestAborted).ConfigureAwait(false);
- if (cachedBytes is not null && cachedBytes.Length > 0)
+ var cached = await ProbeAsync(distributedCache, cacheKey, httpContext.RequestAborted).ConfigureAwait(false);
+ if (cached is not null)
+ {
+ return await ReplayAsync(httpContext, cached, idempotencyKey, logger).ConfigureAwait(false);
+ }
+
+ // Atomically reserve the key so concurrent duplicates don't both execute the handler.
+ var multiplexer = httpContext.RequestServices.GetService();
+ var reservationKey = cacheKey + ":inflight";
+ if (!await TryReserveAsync(multiplexer, reservationKey, options.ReservationTtl, logger, idempotencyKey, httpContext.RequestAborted).ConfigureAwait(false))
+ {
+ // Another request with this key is in flight. It may have finished between the probe
+ // and the reservation — re-probe once, otherwise report the in-progress conflict.
+ var raced = await ProbeAsync(distributedCache, cacheKey, httpContext.RequestAborted).ConfigureAwait(false);
+ return raced is not null
+ ? await ReplayAsync(httpContext, raced, idempotencyKey, logger).ConfigureAwait(false)
+ : TypedResults.Conflict("A request with this Idempotency-Key is already being processed.");
+ }
+
+ try
{
- var cached = JsonSerializer.Deserialize(cachedBytes, JsonOpts);
- if (cached is not null)
+ var result = await next(context).ConfigureAwait(false);
+
+ // Execute the result into a buffer to capture the real wire body + status code, then
+ // serve that buffer to the client. Returning the IResult unexecuted would leave
+ // Response.StatusCode at its default and cache the wrapper object, not the wire body.
+ var (statusCode, contentType, body) = await ExecuteAndCaptureAsync(result, httpContext).ConfigureAwait(false);
+
+ httpContext.Response.StatusCode = statusCode;
+ if (contentType is not null)
{
- if (logger.IsEnabled(LogLevel.Debug))
- {
- logger.LogDebug("Idempotent replay for key {KeyHash}", HashKey(idempotencyKey));
- }
- httpContext.Response.Headers["Idempotency-Replayed"] = "true";
- httpContext.Response.StatusCode = cached.StatusCode;
- if (cached.ContentType is not null)
- {
- httpContext.Response.ContentType = cached.ContentType;
- }
+ httpContext.Response.ContentType = contentType;
+ }
- if (cached.Body.Length > 0)
+ if (body.Length > 0)
+ {
+ await httpContext.Response.Body.WriteAsync(body, httpContext.RequestAborted).ConfigureAwait(false);
+ }
+
+ // Write to the SAME store + key the probe reads. HybridCache.SetAsync keys its L2 entries
+ // under its own scheme, so a raw-key IDistributedCache probe never found them and replay
+ // silently never engaged. Idempotency entries are short-lived (TTL) and their tag-purge
+ // path was unused, so IDistributedCache alone — symmetric with the probe — is correct.
+ try
+ {
+ var responseToCache = new CachedIdempotentResponse
{
- await httpContext.Response.Body.WriteAsync(cached.Body, httpContext.RequestAborted).ConfigureAwait(false);
- }
+ StatusCode = statusCode,
+ ContentType = contentType ?? "application/json",
+ Body = body,
+ };
- return null; // Response already written
+ var payload = JsonSerializer.SerializeToUtf8Bytes(responseToCache, JsonOpts);
+ await distributedCache.SetAsync(
+ cacheKey,
+ payload,
+ new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = options.DefaultTtl },
+ httpContext.RequestAborted).ConfigureAwait(false);
+ }
+ // Best-effort caching: idempotency replay is a convenience, not a correctness requirement
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ logger.LogWarning(ex, "Failed to cache idempotent response for key {KeyHash}", HashKey(idempotencyKey));
}
+
+ // Response already written to the body directly; return an empty result so the framework
+ // doesn't serialize a null return and append "null" after the captured payload.
+ return Results.Empty;
+ }
+ finally
+ {
+ await ReleaseReservationAsync(multiplexer, reservationKey, logger, idempotencyKey).ConfigureAwait(false);
+ }
+ }
+
+ private static async ValueTask ProbeAsync(
+ IDistributedCache cache, string cacheKey, CancellationToken ct)
+ {
+ var bytes = await cache.GetAsync(cacheKey, ct).ConfigureAwait(false);
+ return bytes is { Length: > 0 }
+ ? JsonSerializer.Deserialize(bytes, JsonOpts)
+ : null;
+ }
+
+ private static async ValueTask ReplayAsync(
+ HttpContext httpContext, CachedIdempotentResponse cached, string idempotencyKey, ILogger logger)
+ {
+ if (logger.IsEnabled(LogLevel.Debug))
+ {
+ logger.LogDebug("Idempotent replay for key {KeyHash}", HashKey(idempotencyKey));
+ }
+
+ httpContext.Response.Headers["Idempotency-Replayed"] = "true";
+ httpContext.Response.StatusCode = cached.StatusCode;
+ if (cached.ContentType is not null)
+ {
+ httpContext.Response.ContentType = cached.ContentType;
}
- // Execute the handler
- var result = await next(context).ConfigureAwait(false);
+ if (cached.Body.Length > 0)
+ {
+ await httpContext.Response.Body.WriteAsync(cached.Body, httpContext.RequestAborted).ConfigureAwait(false);
+ }
+
+ // Empty result (not null) so the framework doesn't append a serialized "null".
+ return Results.Empty;
+ }
- // Cache the response through HybridCache so the tag invalidation path works for purges.
+ private static async Task<(int StatusCode, string? ContentType, byte[] Body)> ExecuteAndCaptureAsync(
+ object? result, HttpContext httpContext)
+ {
+ var originalBody = httpContext.Response.Body;
+ await using var buffer = new MemoryStream();
+ httpContext.Response.Body = buffer;
try
{
- var body = result is not null ? JsonSerializer.SerializeToUtf8Bytes(result, JsonOpts) : [];
- var responseToCache = new CachedIdempotentResponse
+ switch (result)
{
- StatusCode = httpContext.Response.StatusCode is > 0 and < 600 ? httpContext.Response.StatusCode : 200,
- ContentType = "application/json",
- Body = body
- };
+ case null:
+ break;
+ case IResult endpointResult:
+ await endpointResult.ExecuteAsync(httpContext).ConfigureAwait(false);
+ break;
+ default:
+ // A non-IResult return is serialized as JSON by the framework — mirror that.
+ await httpContext.Response.WriteAsJsonAsync(result, result.GetType(), options: null, contentType: null, httpContext.RequestAborted).ConfigureAwait(false);
+ break;
+ }
+
+ var statusCode = httpContext.Response.StatusCode is > 0 and < 600
+ ? httpContext.Response.StatusCode
+ : StatusCodes.Status200OK;
+ return (statusCode, httpContext.Response.ContentType, buffer.ToArray());
+ }
+ finally
+ {
+ httpContext.Response.Body = originalBody;
+ }
+ }
- var setOptions = new HybridCacheEntryOptions
+ private static async ValueTask TryReserveAsync(
+ IConnectionMultiplexer? multiplexer, string reservationKey, TimeSpan ttl, ILogger logger, string idempotencyKey, CancellationToken ct)
+ {
+ if (multiplexer is not null)
+ {
+ try
+ {
+ var db = multiplexer.GetDatabase();
+ return await db.StringSetAsync(reservationKey, "1", ttl, When.NotExists).ConfigureAwait(false);
+ }
+ // Fail open on a Redis blip: the reservation is a concurrency convenience, not a correctness
+ // requirement (the response cache still dedups later retries). Proceed rather than 500 the
+ // request, matching the best-effort stance the response write already takes.
+ catch (Exception ex) when (ex is not OperationCanceledException)
{
- Expiration = options.DefaultTtl,
- LocalCacheExpiration = options.DefaultTtl < TimeSpan.FromMinutes(2) ? options.DefaultTtl : TimeSpan.FromMinutes(2),
- };
- await hybridCache.SetAsync(cacheKey, responseToCache, setOptions, tags, httpContext.RequestAborted).ConfigureAwait(false);
+ logger.LogWarning(ex, "Idempotency reservation failed for key {KeyHash}; proceeding without it", HashKey(idempotencyKey));
+ return true;
+ }
}
- // Best-effort caching: idempotency replay is a convenience, not a correctness requirement
- catch (Exception ex) when (ex is not OperationCanceledException)
+
+ _ = ct;
+ return InFlight.TryAdd(reservationKey, 0);
+ }
+
+ private static async ValueTask ReleaseReservationAsync(IConnectionMultiplexer? multiplexer, string reservationKey, ILogger logger, string idempotencyKey)
+ {
+ if (multiplexer is not null)
{
- logger.LogWarning(ex, "Failed to cache idempotent response for key {KeyHash}", HashKey(idempotencyKey));
+ try
+ {
+ await multiplexer.GetDatabase().KeyDeleteAsync(reservationKey).ConfigureAwait(false);
+ }
+ // Best-effort release: a Redis fault here must not throw out of the finally. The short
+ // ReservationTtl expires the key anyway, so a missed delete self-heals in seconds.
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ logger.LogWarning(ex, "Failed to release idempotency reservation for key {KeyHash}", HashKey(idempotencyKey));
+ }
+
+ return;
}
- return result;
+ InFlight.TryRemove(reservationKey, out _);
}
private static string HashKey(string key)
diff --git a/src/BuildingBlocks/Web/Idempotency/IdempotencyOptions.cs b/src/BuildingBlocks/Web/Idempotency/IdempotencyOptions.cs
index fd8bdac92b..73abe99891 100644
--- a/src/BuildingBlocks/Web/Idempotency/IdempotencyOptions.cs
+++ b/src/BuildingBlocks/Web/Idempotency/IdempotencyOptions.cs
@@ -15,6 +15,15 @@ public sealed class IdempotencyOptions
///
public TimeSpan DefaultTtl { get; set; } = TimeSpan.FromHours(24);
+ ///
+ /// Time-to-live for the in-flight reservation that serializes concurrent duplicate keys.
+ /// Decoupled from : it must only outlast the handler's execution, so a
+ /// crash between reserving and releasing frees the key in seconds instead of stranding it for the
+ /// full response TTL (every retry would 409 until it expired). Must exceed the longest expected
+ /// handler runtime — if it lapses mid-request a concurrent duplicate can slip through. Default: 1 minute.
+ ///
+ public TimeSpan ReservationTtl { get; set; } = TimeSpan.FromMinutes(1);
+
///
/// Maximum allowed length for the idempotency key. Default: 128 characters.
///
diff --git a/src/Tests/Framework.Tests/Web/IdempotencyEndpointFilterReplayTests.cs b/src/Tests/Framework.Tests/Web/IdempotencyEndpointFilterReplayTests.cs
new file mode 100644
index 0000000000..bba14bff0c
--- /dev/null
+++ b/src/Tests/Framework.Tests/Web/IdempotencyEndpointFilterReplayTests.cs
@@ -0,0 +1,230 @@
+using System.Text.Json;
+using FSH.Framework.Web.Idempotency;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Caching.Distributed;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using NSubstitute;
+using StackExchange.Redis;
+
+namespace Framework.Tests.Web;
+
+///
+/// Regression for audit findings API-01 (idempotency replay stored the wrong wire shape and status)
+/// and CONC-01 (no in-flight reservation, so concurrent duplicate keys executed twice). These
+/// exercise the REAL against a real in-memory
+/// — the same store the filter now uses for both probe and write.
+///
+public sealed class IdempotencyEndpointFilterReplayTests
+{
+ private const string Key = "fixed-idempotency-key";
+
+ // ─── API-01: replayed status ─────────────────────────────────────
+
+ [Fact]
+ public async Task Replay_Should_PreserveCreatedStatus_When_FirstResponseWas201()
+ {
+ var provider = BuildProvider();
+ var filter = new IdempotencyEndpointFilter();
+ var id = Guid.NewGuid();
+
+ // First call: handler returns a 201 Created (the framework would execute it AFTER the filter
+ // returns — so at cache time Response.StatusCode is still the default 200).
+ var first = NewContext(provider);
+ await filter.InvokeAsync(
+ new TestFilterContext(first),
+ _ => ValueTask.FromResult(TypedResults.Created($"/samples/{id}", new SampleDto(id, "widget"))));
+
+ // Second call, same key: must replay.
+ var replayBody = new MemoryStream();
+ var second = NewContext(provider, replayBody);
+ await filter.InvokeAsync(
+ new TestFilterContext(second),
+ _ => throw new InvalidOperationException("handler must NOT run on an idempotent replay"));
+
+ second.Response.Headers.ContainsKey("Idempotency-Replayed").ShouldBeTrue(
+ "sanity: the replay path must actually engage, otherwise this test would be vacuous");
+ second.Response.StatusCode.ShouldBe(
+ StatusCodes.Status201Created,
+ "a correct replay must reproduce the original 201 Created — the filter captures Response.StatusCode " +
+ "BEFORE the IResult executes, so it caches (and replays) 200 instead.");
+ }
+
+ // ─── API-01: replayed body wire shape ────────────────────────────
+
+ [Fact]
+ public async Task Replay_Should_ReturnPlainDtoBody_Not_WrappedIResult()
+ {
+ var provider = BuildProvider();
+ var filter = new IdempotencyEndpointFilter();
+ var id = Guid.NewGuid();
+
+ var first = NewContext(provider);
+ await filter.InvokeAsync(
+ new TestFilterContext(first),
+ _ => ValueTask.FromResult(TypedResults.Ok(new SampleDto(id, "widget"))));
+
+ var replayBody = new MemoryStream();
+ var second = NewContext(provider, replayBody);
+ await filter.InvokeAsync(
+ new TestFilterContext(second),
+ _ => throw new InvalidOperationException("handler must NOT run on an idempotent replay"));
+
+ replayBody.Position = 0;
+ using var doc = JsonDocument.Parse(replayBody.ToArray());
+
+ doc.RootElement.TryGetProperty("value", out _).ShouldBeFalse(
+ "a correct replay body is the wire DTO; the filter caches SerializeToUtf8Bytes(result) where " +
+ "result is the wrapped Ok/Created, leaking the {\"value\":...} envelope onto the wire.");
+ doc.RootElement.TryGetProperty("id", out _).ShouldBeTrue(
+ "the plain DTO's own properties should be at the JSON root");
+ }
+
+ // ─── CONC-01: no in-flight reservation ───────────────────────────
+
+ [Fact]
+ public async Task Filter_Should_ExecuteHandlerOnce_When_TwoConcurrentRequestsShareKey()
+ {
+ var provider = BuildProvider();
+ var filter = new IdempotencyEndpointFilter();
+
+ int executions = 0;
+ var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ // First request enters the handler and holds the in-flight reservation until released.
+ EndpointFilterDelegate first = async _ =>
+ {
+ Interlocked.Increment(ref executions);
+ started.SetResult();
+ await release.Task.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
+ return TypedResults.Ok(new SampleDto(Guid.NewGuid(), "first"));
+ };
+
+ // Second request shares the key; its handler must never run while the first is in flight.
+ EndpointFilterDelegate second = _ =>
+ {
+ Interlocked.Increment(ref executions);
+ return ValueTask.FromResult(TypedResults.Ok(new SampleDto(Guid.NewGuid(), "second")));
+ };
+
+ var firstCall = filter.InvokeAsync(new TestFilterContext(NewContext(provider)), first).AsTask();
+ await started.Task.WaitAsync(TimeSpan.FromSeconds(5)); // first now holds the reservation
+
+ var secondResult = await filter.InvokeAsync(new TestFilterContext(NewContext(provider)), second);
+
+ release.SetResult();
+ await firstCall.WaitAsync(TimeSpan.FromSeconds(10));
+
+ executions.ShouldBe(
+ 1,
+ "an idempotent endpoint must execute the handler exactly once for concurrent duplicate keys; " +
+ "the second request should be rejected while the first is in flight.");
+ (secondResult as IStatusCodeHttpResult)?.StatusCode.ShouldBe(
+ StatusCodes.Status409Conflict,
+ "a concurrent duplicate that arrives while the original is still running gets 409 Conflict.");
+ }
+
+ // ─── HIGH: reservation TTL is the short ReservationTtl, not the 24h response TTL ─────
+
+ [Fact]
+ public async Task Reservation_Should_UseReservationTtl_Not_ResponseTtl()
+ {
+ var options = new IdempotencyOptions
+ {
+ ReservationTtl = TimeSpan.FromSeconds(37), // distinct from DefaultTtl to prove which one is used
+ };
+ var db = Substitute.For();
+ TimeSpan? capturedTtl = null;
+ db.StringSetAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(ci => { capturedTtl = ci.ArgAt(2); return Task.FromResult(true); });
+ var provider = BuildProvider(options, RedisMultiplexer(db));
+ var filter = new IdempotencyEndpointFilter();
+
+ await filter.InvokeAsync(
+ new TestFilterContext(NewContext(provider)),
+ _ => ValueTask.FromResult(TypedResults.Ok(new SampleDto(Guid.NewGuid(), "widget"))));
+
+ capturedTtl.ShouldBe(
+ options.ReservationTtl,
+ "the in-flight reservation must use the short ReservationTtl; keying it to the 24h response TTL " +
+ "would strand the lock for a day if the process is killed before the finally-release runs.");
+ capturedTtl.ShouldNotBe(options.DefaultTtl);
+ }
+
+ // ─── MEDIUM + nit: a Redis fault on reserve/release fails open, never 500s ───────────
+
+ [Fact]
+ public async Task Filter_Should_ProceedWithoutThrowing_When_RedisReservationFaults()
+ {
+ var db = Substitute.For();
+ db.StringSetAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(Task.FromException(new RedisException("reserve blip")));
+ db.KeyDeleteAsync(Arg.Any(), Arg.Any())
+ .Returns(Task.FromException(new RedisException("release blip")));
+ var provider = BuildProvider(new IdempotencyOptions(), RedisMultiplexer(db));
+ var filter = new IdempotencyEndpointFilter();
+
+ int executions = 0;
+ var result = await filter.InvokeAsync(
+ new TestFilterContext(NewContext(provider)),
+ _ => { executions++; return ValueTask.FromResult(TypedResults.Ok(new SampleDto(Guid.NewGuid(), "widget"))); });
+
+ executions.ShouldBe(
+ 1,
+ "a transient Redis error on the reservation must fail open — the handler still runs. On main " +
+ "idempotency degraded gracefully; treating the reservation as authoritative would 500 the request.");
+ result.ShouldNotBeNull("the request must complete normally, not throw out of the filter");
+ }
+
+ // ─── harness ─────────────────────────────────────────────────────
+
+ private static ServiceProvider BuildProvider() => BuildProvider(new IdempotencyOptions(), multiplexer: null);
+
+ private static ServiceProvider BuildProvider(IdempotencyOptions options, IConnectionMultiplexer? multiplexer)
+ {
+ var services = new ServiceCollection();
+ services.AddLogging();
+ services.AddDistributedMemoryCache();
+ services.AddSingleton>(Options.Create(options));
+ if (multiplexer is not null)
+ {
+ services.AddSingleton(multiplexer);
+ }
+
+ return services.BuildServiceProvider();
+ }
+
+ private static IConnectionMultiplexer RedisMultiplexer(IDatabase db)
+ {
+ var mux = Substitute.For();
+ mux.GetDatabase(Arg.Any(), Arg.Any()).Returns(db);
+ return mux;
+ }
+
+ private static DefaultHttpContext NewContext(IServiceProvider provider, Stream? responseBody = null)
+ {
+ var context = new DefaultHttpContext { RequestServices = provider };
+ context.Request.Method = "POST";
+ context.Request.Headers["Idempotency-Key"] = Key;
+ if (responseBody is not null)
+ {
+ context.Response.Body = responseBody;
+ }
+
+ return context;
+ }
+
+ private sealed record SampleDto(Guid Id, string Name);
+
+ private sealed class TestFilterContext : EndpointFilterInvocationContext
+ {
+ public TestFilterContext(HttpContext httpContext) => HttpContext = httpContext;
+
+ public override HttpContext HttpContext { get; }
+
+ public override IList Arguments { get; } = new List();
+
+ public override T GetArgument(int index) => (T)Arguments[index]!;
+ }
+}
diff --git a/src/Tests/Integration.Tests/Tests/Chat/ChatSendMessageTests.cs b/src/Tests/Integration.Tests/Tests/Chat/ChatSendMessageTests.cs
index 004a72066a..28bbd65535 100644
--- a/src/Tests/Integration.Tests/Tests/Chat/ChatSendMessageTests.cs
+++ b/src/Tests/Integration.Tests/Tests/Chat/ChatSendMessageTests.cs
@@ -71,7 +71,7 @@ public async Task SendMessage_Should_Trim_Body_Whitespace()
// ─── idempotency ─────────────────────────────────────────────────
- [Fact(Skip = "Idempotency replay does not engage in the test environment — IDistributedCache (probe) and HybridCache (write-through) are wired to separate in-process stores, so the second call never sees the cached response. Same caveat as IdempotencyFilterTests.cs: 'full replay-with-matching-body coverage is not yet possible'. Backlog item 2.4b tracks the fix.")]
+ [Fact]
public async Task SendMessage_Should_Replay_Same_Response_When_Idempotency_Key_Reused()
{
using var client = await _auth.CreateRootAdminClientAsync();