From 1404df4978935832c7d40499ec889df7200f2d2a Mon Sep 17 00:00:00 2001
From: Eric Sondergard <4650644+esond@users.noreply.github.com>
Date: Wed, 4 Nov 2026 21:43:31 -0600
Subject: [PATCH 1/8] [DeduplicatedWithResponse] replays ASP.NET Core IResult
responses
An endpoint that returns an IResult was refused at startup, because its writer is IResult.ExecuteAsync
rather than WriteJsonFrame. CompleteDeduplicatedRequest runs before that call, so the response is now
described from the result itself: Results<...> unions are unwrapped, the status comes from
IStatusCodeHttpResult, the body is the IValueHttpResult value serialized as ASP.NET Core's
HttpResultsHelper writes it (its own JsonOptions, the declared TValue unless the runtime type differs and
TValue has no known polymorphism), and the Location comes from Created and Accepted.
Only Ok, Created, Accepted (with or without a value), NoContent, StatusCodeHttpResult and EmptyHttpResult
are stored. A status of 400 or above, a challenge or a forbid gives the claim back as any failure does. Any
other result is done work whose response cannot be stored: the claim is kept unanswered, so repeats get
409, and an error names the result type. An endpoint declared to return only results that can never be
stored is refused at startup, from the same classification.
---
.../HttpChain.DeduplicatedWithResponse.cs | 14 ++-
src/Http/Wolverine.Http/HttpHandler.cs | 94 ++++++++++++++++++-
2 files changed, 104 insertions(+), 4 deletions(-)
diff --git a/src/Http/Wolverine.Http/HttpChain.DeduplicatedWithResponse.cs b/src/Http/Wolverine.Http/HttpChain.DeduplicatedWithResponse.cs
index 3efd52fd7..0c34452dd 100644
--- a/src/Http/Wolverine.Http/HttpChain.DeduplicatedWithResponse.cs
+++ b/src/Http/Wolverine.Http/HttpChain.DeduplicatedWithResponse.cs
@@ -3,6 +3,7 @@
using JasperFx.CodeGeneration;
using JasperFx.CodeGeneration.Frames;
using JasperFx.CodeGeneration.Model;
+using JasperFx.Core.Reflection;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
@@ -94,6 +95,12 @@ private void assertDeduplicatedWithResponseIsValid(DeduplicatedWithResponseRequi
$"[DeduplicatedWithResponse] on {Description} returns no resource, so a repeat has no response to replay. Use [Deduplicated] instead. See GH-4742");
}
+ if (typeof(IResult).IsAssignableFrom(ResourceType) && !HttpHandler.CanReplayResultOf(ResourceType!))
+ {
+ throw new NotSupportedException(
+ $"{Description} has [DeduplicatedWithResponse], which replays the response as its status, System.Text.Json body and Location, but this endpoint returns {ResourceType!.FullNameInCode()}, which writes its response another way. Return Ok, Created, Accepted or NoContent results, or a resource written as JSON. See GH-4742");
+ }
+
if (BindsFormValues)
{
// The fingerprint reads the body after binding, which needs a rewindable stream --
@@ -288,8 +295,11 @@ private void warnClaimInTransactionFallsBack(IServiceContainer container, bool a
/// Called from DetermineFrames once the response writer is chosen.
private void assertDeduplicatedWithResponseIsJson()
{
- // The stored body is System.Text.Json; any other writer would replay different bytes.
- if (DeduplicatedWithResponse != null && !Postprocessors.OfType().Any())
+ // The stored body is System.Text.Json; any other writer would replay different bytes. An IResult is
+ // described per request by HttpHandler.CompleteDeduplicatedRequest, which stores only the results it can.
+ if (DeduplicatedWithResponse != null && !Postprocessors.OfType().Any() &&
+ !Postprocessors.OfType().Any(x =>
+ x.Method.DeclaringType == typeof(IResult) && x.Method.Name == nameof(IResult.ExecuteAsync)))
{
throw new NotSupportedException(
$"{Description} has [DeduplicatedWithResponse], which replays the response as System.Text.Json, but this endpoint writes its response another way. See GH-4742");
diff --git a/src/Http/Wolverine.Http/HttpHandler.cs b/src/Http/Wolverine.Http/HttpHandler.cs
index 59f16a700..973f8ea90 100644
--- a/src/Http/Wolverine.Http/HttpHandler.cs
+++ b/src/Http/Wolverine.Http/HttpHandler.cs
@@ -10,6 +10,7 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Http.Headers;
+using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Http.Metadata;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.WebUtilities;
@@ -503,8 +504,8 @@ public static bool IsDeduplicatedResponseCommitted(HttpContext context)
///
/// GH-4742. Called once the endpoint has run, after any commit on claim-and-release and just before it when the
/// claim is written inside the transaction: the response about to be written, for the claim to store, as
- /// will write it, with the status and Location already set.
- /// Unless that response is a failure (400 and up), the work is done and the claim is kept from here on. Null
+ /// will write it, with the status and Location already set; an
+ /// is described from the result, before it executes. Unless that response is a failure (400 and up), the work is done and the claim is kept from here on. Null
/// records nothing.
///
public DeduplicatedResponse? CompleteDeduplicatedRequest(HttpContext context, string? deduplicationId,
@@ -512,6 +513,8 @@ public static bool IsDeduplicatedResponseCommitted(HttpContext context)
{
if (string.IsNullOrWhiteSpace(deduplicationId)) return null;
+ if (resource is IResult result) return completeDeduplicatedResult(context, deduplicationId, result);
+
var status = resource is null ? missingResourceStatusCode : context.Response.StatusCode;
if (status >= 400) return null;
@@ -534,6 +537,93 @@ public static bool IsDeduplicatedResponseCommitted(HttpContext context)
}
}
+ // The results whose whole response is their status, JSON value and Location, so a replay writes the same bytes
+ private static readonly HashSet _replayableResults =
+ [
+ typeof(Ok), typeof(Ok<>), typeof(Created), typeof(Created<>), typeof(Accepted), typeof(Accepted<>),
+ typeof(NoContent), typeof(StatusCodeHttpResult), typeof(EmptyHttpResult)
+ ];
+
+ private static readonly HashSet _resultUnions =
+ [typeof(Results<,>), typeof(Results<,,>), typeof(Results<,,,>), typeof(Results<,,,,>), typeof(Results<,,,,,>)];
+
+ private static bool isReplayableResult(Type type)
+ => _replayableResults.Contains(type.IsGenericType ? type.GetGenericTypeDefinition() : type);
+
+ ///
+ /// Can a declared ever be a result [DeduplicatedWithResponse] can store? An
+ /// interface, or a nested result other than Results<...> , is only known per request.
+ ///
+ internal static bool CanReplayResultOf(Type resultType)
+ {
+ if (resultType.IsGenericType && _resultUnions.Contains(resultType.GetGenericTypeDefinition()))
+ {
+ return resultType.GetGenericArguments().Any(CanReplayResultOf);
+ }
+
+ return resultType.IsInterface || typeof(INestedHttpResult).IsAssignableFrom(resultType) ||
+ isReplayableResult(resultType);
+ }
+
+ ///
+ /// An has not executed yet, so its response is described from the result, as its
+ /// ExecuteAsync will write it.
+ ///
+ private static DeduplicatedResponse? completeDeduplicatedResult(HttpContext context, string deduplicationId,
+ IResult result)
+ {
+ while (result is INestedHttpResult nested) result = nested.Result;
+
+ var status = (result as IStatusCodeHttpResult)?.StatusCode ?? context.Response.StatusCode;
+ if (status >= 400 || result is ChallengeHttpResult or ForbidHttpResult) return null;
+
+ context.Items[_deduplicatedWorkDone] = true;
+
+ var logger = context.RequestServices.GetService>();
+ var type = result.GetType();
+
+ if (!isReplayableResult(type))
+ {
+ // The work is done, so the claim stays: repeats get 409 until it expires rather than running it again.
+ logger?.LogError(
+ "The {ResultType} answering deduplicated id '{DeduplicationId}' is not stored, because it writes more than a status, a JSON body and a Location",
+ type.FullNameInCode(), deduplicationId);
+ return null;
+ }
+
+ try
+ {
+ // Created and Accepted, with or without a value; ExecuteAsync sets the header only when it is not empty
+ var location = type.GetProperty(nameof(Created.Location))?.GetValue(result) as string;
+
+ return new DeduplicatedResponse(status,
+ result is IValueHttpResult { Value: { } value } ? serializeAsResultWrites(context, type, value) : null,
+ string.IsNullOrEmpty(location) ? null : location);
+ }
+ catch (Exception e)
+ {
+ logger?.LogError(e,
+ "Failed to serialize the response for deduplicated id '{DeduplicationId}' to store it", deduplicationId);
+ return null;
+ }
+ }
+
+ // As ASP.NET Core's HttpResultsHelper.WriteResultAsJsonAsync writes Ok, Created and Accepted: with its own
+ // JsonOptions, as the declared TValue unless the runtime type differs and TValue has no known polymorphism.
+ private static string serializeAsResultWrites(HttpContext context, Type resultType, object value)
+ {
+ var options = (context.RequestServices.GetService>()?.Value
+ ?? new Microsoft.AspNetCore.Http.Json.JsonOptions()).SerializerOptions;
+
+ var declared = resultType.GetGenericArguments()[0];
+ var typeInfo = options.GetTypeInfo(declared);
+
+ return value.GetType() == declared || declared.IsSealed || declared.IsValueType ||
+ typeInfo.PolymorphismOptions != null
+ ? JsonSerializer.Serialize(value, typeInfo)
+ : JsonSerializer.Serialize(value, options);
+ }
+
/// GH-4742. Has the endpoint's work been done, so its claim must be kept?
public static bool IsDeduplicatedWorkDone(HttpContext context) => context.Items.ContainsKey(_deduplicatedWorkDone);
From 4d4b8148b2b93276c90b17a4e9eb8e4883411748 Mon Sep 17 00:00:00 2001
From: Eric Sondergard <4650644+esond@users.noreply.github.com>
Date: Wed, 4 Nov 2026 21:47:27 -0600
Subject: [PATCH 2/8] Test [DeduplicatedWithResponse] on IResult endpoints
Created, Ok with a derived value, NoContent and both arms of a Results, NotFound> union on
claim-and-release; a text result that is kept unanswered; the same union claimed inside the Marten
transaction; and the startup refusal of a file result next to an accepted union.
---
.../deduplicated_with_response_on_marten.cs | 45 ++++++
.../deduplicated_with_response.cs | 147 ++++++++++++++++++
.../deduplicated_with_response_declaration.cs | 34 +++-
3 files changed, 225 insertions(+), 1 deletion(-)
diff --git a/src/Http/Wolverine.Http.Tests/Marten/deduplicated_with_response_on_marten.cs b/src/Http/Wolverine.Http.Tests/Marten/deduplicated_with_response_on_marten.cs
index e6ab95064..16015b2a2 100644
--- a/src/Http/Wolverine.Http.Tests/Marten/deduplicated_with_response_on_marten.cs
+++ b/src/Http/Wolverine.Http.Tests/Marten/deduplicated_with_response_on_marten.cs
@@ -9,6 +9,7 @@
using Marten.Services;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
@@ -405,6 +406,36 @@ public async Task a_failure_answer_claims_nothing_and_its_retry_runs()
MartenDeduplicatedResponseEndpoints.Runs(request.Name).ShouldBe(2);
}
+ [Fact]
+ public async Task a_result_union_is_claimed_inside_the_commit_and_replayed()
+ {
+ var key = Guid.NewGuid().ToString();
+ var request = new DeduplicatedOrder("result-" + key);
+
+ var first = await postAsync("/marten-deduplicated-response/result", request, key, 201);
+ var repeat = await postAsync("/marten-deduplicated-response/result", request, key, 201);
+
+ (await repeat.ReadAsTextAsync()).ShouldBe(await first.ReadAsTextAsync());
+ repeat.Context.Response.Headers.Location.ToString()
+ .ShouldBe(first.Context.Response.Headers.Location.ToString());
+ MartenDeduplicatedResponseEndpoints.Runs(request.Name).ShouldBe(1);
+
+ sourceFor("/marten-deduplicated-response/result").ShouldNotContain(".TryClaimAsync(");
+ }
+
+ [Fact]
+ public async Task the_failure_arm_of_a_result_union_claims_nothing_and_its_retry_runs()
+ {
+ var key = Guid.NewGuid().ToString();
+ var request = new DeduplicatedOrder(MartenDeduplicatedResponseEndpoints.Absent + key);
+
+ await postAsync("/marten-deduplicated-response/result", request, key, 404);
+ (await findClaimAsync(key)).ShouldBeNull();
+
+ await postAsync("/marten-deduplicated-response/result", request, key, 404);
+ MartenDeduplicatedResponseEndpoints.Runs(request.Name).ShouldBe(2);
+ }
+
[Fact]
public async Task a_request_without_an_optional_key_runs_and_claims_nothing()
{
@@ -925,6 +956,20 @@ public static DeduplicatedOrderCreated PostOrderByDefault(DeduplicatedOrder requ
return null;
}
+ public const string Absent = "absent-";
+
+ [DeduplicatedWithResponse(DeduplicationScope.User, ClaimInTransaction = true)]
+ [WolverinePost("/marten-deduplicated-response/result")]
+ public static Results, NotFound> PostResult(DeduplicatedOrder request,
+ IDocumentSession session)
+ {
+ // Either way the work commits; only the Created answer is claimed
+ var created = PostOrder(request, session);
+ if (request.Name.StartsWith(Absent)) return TypedResults.NotFound();
+
+ return TypedResults.Created($"/marten-deduplicated-response/orders/{created.Id}", created);
+ }
+
[DeduplicatedWithResponse(DeduplicationScope.User, ClaimInTransaction = true, Required = false)]
[WolverinePost("/marten-deduplicated-response/optional-key")]
public static DeduplicatedOrderCreated PostOptionalKey(DeduplicatedOrder request, IDocumentSession session)
diff --git a/src/Http/Wolverine.Http.Tests/deduplicated_with_response.cs b/src/Http/Wolverine.Http.Tests/deduplicated_with_response.cs
index bb212864f..d8c833619 100644
--- a/src/Http/Wolverine.Http.Tests/deduplicated_with_response.cs
+++ b/src/Http/Wolverine.Http.Tests/deduplicated_with_response.cs
@@ -7,6 +7,7 @@
using JasperFx.Resources;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
@@ -337,6 +338,101 @@ public void a_response_that_is_not_system_text_json_is_refused()
ex.Message.ShouldContain("System.Text.Json");
}
+ [Fact]
+ public async Task a_created_result_is_replayed_with_its_status_body_and_location()
+ {
+ var key = Guid.NewGuid().ToString();
+ var request = new DeduplicatedOrder("created-result");
+
+ var first = await postAsync(request, key, 201, url: "/deduplicated-response/created-result");
+ var repeat = await postAsync(request, key, 201, url: "/deduplicated-response/created-result");
+
+ var body = await first.ReadAsTextAsync();
+ body.ShouldNotBeNullOrEmpty();
+ (await repeat.ReadAsTextAsync()).ShouldBe(body);
+
+ var location = first.Context.Response.Headers.Location.ToString();
+ location.ShouldStartWith("/deduplicated-response/orders/");
+ repeat.Context.Response.Headers.Location.ToString().ShouldBe(location);
+
+ DeduplicatedResponseEndpoints.Runs(request.Name).ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task an_ok_result_is_replayed_as_its_value_was_written()
+ {
+ // The declared value type is a base record; the result writes the derived one's members, so must the replay
+ var key = Guid.NewGuid().ToString();
+ var request = new DeduplicatedOrder("ok-result");
+
+ var first = await postAsync(request, key, 200, url: "/deduplicated-response/ok-result");
+ var repeat = await postAsync(request, key, 200, url: "/deduplicated-response/ok-result");
+
+ var body = await first.ReadAsTextAsync();
+ body.ShouldContain("\"detail\"");
+ (await repeat.ReadAsTextAsync()).ShouldBe(body);
+ repeat.Context.Response.ContentType.ShouldBe(first.Context.Response.ContentType);
+
+ DeduplicatedResponseEndpoints.Runs(request.Name).ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task a_no_content_result_is_replayed_with_no_body()
+ {
+ var key = Guid.NewGuid().ToString();
+ var request = new DeduplicatedOrder("no-content-result");
+
+ await postAsync(request, key, 204, url: "/deduplicated-response/no-content-result");
+ var repeat = await postAsync(request, key, 204, url: "/deduplicated-response/no-content-result");
+
+ (await repeat.ReadAsTextAsync()).ShouldBeEmpty();
+ DeduplicatedResponseEndpoints.Runs(request.Name).ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task the_success_arm_of_a_result_union_is_replayed()
+ {
+ var key = Guid.NewGuid().ToString();
+ var request = new DeduplicatedOrder("union-result");
+
+ var first = await postAsync(request, key, 201, url: "/deduplicated-response/union-result");
+ var repeat = await postAsync(request, key, 201, url: "/deduplicated-response/union-result");
+
+ (await repeat.ReadAsTextAsync()).ShouldBe(await first.ReadAsTextAsync());
+ repeat.Context.Response.Headers.Location.ToString()
+ .ShouldBe(first.Context.Response.Headers.Location.ToString());
+
+ DeduplicatedResponseEndpoints.Runs(request.Name).ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task the_failure_arm_of_a_result_union_releases_the_claim()
+ {
+ var key = Guid.NewGuid().ToString();
+ var request = new DeduplicatedOrder(DeduplicatedResponseEndpoints.Absent);
+
+ await postAsync(request, key, 404, url: "/deduplicated-response/union-result");
+ await postAsync(request, key, 404, url: "/deduplicated-response/union-result");
+
+ DeduplicatedResponseEndpoints.Runs(request.Name).ShouldBe(2);
+ }
+
+ [Fact]
+ public async Task a_result_that_cannot_be_stored_keeps_the_claim_and_refuses_repeats()
+ {
+ // A text result writes text/plain, which a replay cannot reproduce. The work was done, so it is not run
+ // again, and there is no response to answer with
+ var key = Guid.NewGuid().ToString();
+ var request = new DeduplicatedOrder("text-result");
+
+ var first = await postAsync(request, key, 200, url: "/deduplicated-response/text-result");
+ (await first.ReadAsTextAsync()).ShouldBe(request.Name);
+
+ await postAsync(request, key, 409, url: "/deduplicated-response/text-result");
+
+ DeduplicatedResponseEndpoints.Runs(request.Name).ShouldBe(1);
+ }
+
[Fact]
public async Task a_replay_does_not_re_send_the_first_requests_cascaded_messages()
{
@@ -492,6 +588,7 @@ public static class DeduplicatedResponseEndpoints
public const string Throw = "throw";
public const string Missing = "missing";
public const string Slow = "slow";
+ public const string Absent = "absent";
private static readonly Dictionary _runs = new();
private static TaskCompletionSource _slowArrived = new(TaskCreationOptions.RunContinuationsAsynchronously);
@@ -573,8 +670,58 @@ public static ProblemDetails Validate(DeduplicatedOrder request)
[DeduplicatedWithResponse(DeduplicationScope.User)]
[WolverinePost("/deduplicated-response/text")]
public static string PostText(DeduplicatedOrder request) => request.Name;
+
+ [DeduplicatedWithResponse(DeduplicationScope.User)]
+ [WolverinePost("/deduplicated-response/created-result")]
+ public static Created PostCreatedResult(DeduplicatedOrder request)
+ {
+ var created = run(request);
+ return TypedResults.Created($"/deduplicated-response/orders/{created.Id}", created);
+ }
+
+ [DeduplicatedWithResponse(DeduplicationScope.User)]
+ [WolverinePost("/deduplicated-response/ok-result")]
+ public static Ok PostOkResult(DeduplicatedOrder request)
+ {
+ Count(request.Name);
+ return TypedResults.Ok(new DetailedDeduplicatedAnswer(request.Name, Guid.NewGuid().ToString()));
+ }
+
+ [DeduplicatedWithResponse(DeduplicationScope.User)]
+ [WolverinePost("/deduplicated-response/no-content-result")]
+ public static NoContent PostNoContentResult(DeduplicatedOrder request)
+ {
+ Count(request.Name);
+ return TypedResults.NoContent();
+ }
+
+ [DeduplicatedWithResponse(DeduplicationScope.User)]
+ [WolverinePost("/deduplicated-response/union-result")]
+ public static Results, NotFound> PostUnionResult(DeduplicatedOrder request)
+ {
+ if (request.Name == Absent)
+ {
+ Count(request.Name);
+ return TypedResults.NotFound();
+ }
+
+ var created = run(request);
+ return TypedResults.Created($"/deduplicated-response/orders/{created.Id}", created);
+ }
+
+ [DeduplicatedWithResponse(DeduplicationScope.User)]
+ [WolverinePost("/deduplicated-response/text-result")]
+ public static IResult PostTextResult(DeduplicatedOrder request)
+ {
+ Count(request.Name);
+ return TypedResults.Text(request.Name);
+ }
}
+public record DeduplicatedAnswer(string Name);
+
+public record DetailedDeduplicatedAnswer(string Name, string Detail) : DeduplicatedAnswer(Name);
+
// Its own class: the Validate middleware above needs a body.
public static class MissingResourceDeduplicatedEndpoint
{
diff --git a/src/Http/Wolverine.Http.Tests/deduplicated_with_response_declaration.cs b/src/Http/Wolverine.Http.Tests/deduplicated_with_response_declaration.cs
index f829680e4..0a9ef0812 100644
--- a/src/Http/Wolverine.Http.Tests/deduplicated_with_response_declaration.cs
+++ b/src/Http/Wolverine.Http.Tests/deduplicated_with_response_declaration.cs
@@ -4,6 +4,7 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Http.Metadata;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
@@ -45,6 +46,19 @@ public void an_endpoint_with_no_resource_is_refused_at_startup()
.Message.ShouldContain("returns no resource");
}
+ [Fact]
+ public void a_result_that_is_never_written_as_json_is_refused_at_startup()
+ {
+ Should.Throw(() => HttpChain.ChainFor(typeof(FileResultDeduplicatedResponse), "Post"))
+ .Message.ShouldContain(nameof(FileContentHttpResult));
+ }
+
+ [Fact]
+ public void a_result_union_with_a_replayable_arm_is_not_refused()
+ {
+ Should.NotThrow(() => HttpChain.ChainFor(typeof(UnionResultDeduplicatedResponse), "Post"));
+ }
+
[Fact]
public void a_negative_window_is_refused_at_startup()
{
@@ -322,7 +336,7 @@ public void Log(LogLevel logLevel, EventId eventId, TState state, Except
}
}
-// The four below are invalid on purpose. Hidden from discovery, so no other host scanning this assembly fails
+// The five below are invalid on purpose. Hidden from discovery, so no other host scanning this assembly fails
// to start; the tests build their chains directly.
[WolverineIgnore]
@@ -352,6 +366,14 @@ public static void Post(DeduplicatedOrder request)
}
}
+[WolverineIgnore]
+public static class FileResultDeduplicatedResponse
+{
+ [DeduplicatedWithResponse(DeduplicationScope.User)]
+ [WolverinePost("/declaration/file-result")]
+ public static FileContentHttpResult Post(DeduplicatedOrder request) => TypedResults.File([1, 2, 3]);
+}
+
[WolverineIgnore]
public static class NegativeWindowDeduplicatedResponse
{
@@ -360,6 +382,16 @@ public static class NegativeWindowDeduplicatedResponse
public static DeduplicatedOrderCreated Post(DeduplicatedOrder request) => new(Guid.NewGuid());
}
+// Valid, but hidden from discovery like the ones above: its test only builds the chain.
+[WolverineIgnore]
+public static class UnionResultDeduplicatedResponse
+{
+ [DeduplicatedWithResponse(DeduplicationScope.User)]
+ [WolverinePost("/declaration/union-result")]
+ public static Results, NotFound> Post(DeduplicatedOrder request)
+ => TypedResults.NotFound();
+}
+
public static class StorelessDeduplicatedResponse
{
[DeduplicatedWithResponse(DeduplicationScope.User)]
From fa8eaf1e107dfa48f5160e964e7aad1545c49c10 Mon Sep 17 00:00:00 2001
From: Eric Sondergard <4650644+esond@users.noreply.github.com>
Date: Wed, 4 Nov 2026 21:47:29 -0600
Subject: [PATCH 3/8] Document which IResult responses
[DeduplicatedWithResponse] replays
---
docs/guide/http/deduplication.md | 12 ++++++++++--
1 file changed, 10 insertions(+), 2 deletions(-)
diff --git a/docs/guide/http/deduplication.md b/docs/guide/http/deduplication.md
index fe421cc12..87adcef7b 100644
--- a/docs/guide/http/deduplication.md
+++ b/docs/guide/http/deduplication.md
@@ -337,8 +337,16 @@ releasing it with a rolled-back transaction.
- `Durability.EnableDeduplicatedResponses`, and the PostgreSQL, SQL Server, MySQL or SQLite message store
(including Marten's). With a database per tenant, claims are kept in the main database. Without them
the host logs a warning at startup and the endpoint throws at its first request.
-- The endpoint must return a resource, written as JSON by System.Text.Json; any other response writer is
- refused when the endpoint is first compiled.
+- The endpoint must return a resource, written as JSON by System.Text.Json, or an `IResult` (below); any
+ other response writer is refused when the endpoint is first compiled.
+- An endpoint can also return an ASP.NET Core `IResult`. `Ok`, `Created`, `Accepted` (each with or without
+ a value), `NoContent`, `StatusCodeHttpResult` and `Results.Empty` are replayed with their status, JSON
+ value and `Location`, the value serialized as ASP.NET Core writes it. A `Results<...>` union is replayed
+ when the arm it answers with is one of these. An arm with a status of 400 or above, or a challenge or
+ forbid result, gives the key back as any failure does. Any other result, such as text, files, redirects,
+ `JsonHttpResult` or `CreatedAtRoute`, is not stored: the key is kept unanswered, so repeats get 409 until
+ it expires, and an error is logged. An endpoint whose declared return type can never be one of the
+ replayed results is refused at startup.
- The request body is buffered to compute the fingerprint, and the resource is serialized a second
time to store it.
- Only the status, body and `Location` are replayed; other response headers are not.
From c7438c29116978662038332fdc8737675d97d7b9 Mon Sep 17 00:00:00 2001
From: Eric Sondergard <4650644+esond@users.noreply.github.com>
Date: Wed, 4 Nov 2026 21:49:17 -0600
Subject: [PATCH 4/8] Wrap the CompleteDeduplicatedRequest summary at 120
columns
---
src/Http/Wolverine.Http/HttpHandler.cs | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/Http/Wolverine.Http/HttpHandler.cs b/src/Http/Wolverine.Http/HttpHandler.cs
index 973f8ea90..5d021e4ed 100644
--- a/src/Http/Wolverine.Http/HttpHandler.cs
+++ b/src/Http/Wolverine.Http/HttpHandler.cs
@@ -505,8 +505,8 @@ public static bool IsDeduplicatedResponseCommitted(HttpContext context)
/// GH-4742. Called once the endpoint has run, after any commit on claim-and-release and just before it when the
/// claim is written inside the transaction: the response about to be written, for the claim to store, as
/// will write it, with the status and Location already set; an
- /// is described from the result, before it executes. Unless that response is a failure (400 and up), the work is done and the claim is kept from here on. Null
- /// records nothing.
+ /// is described from the result, before it executes. Unless that response is a failure
+ /// (400 and up), the work is done and the claim is kept from here on. Null records nothing.
///
public DeduplicatedResponse? CompleteDeduplicatedRequest(HttpContext context, string? deduplicationId,
T? resource, int missingResourceStatusCode)
From ad44d441df04261d42bd79268ba30e9ce8962ccb Mon Sep 17 00:00:00 2001
From: Eric Sondergard <4650644+esond@users.noreply.github.com>
Date: Wed, 4 Nov 2026 22:05:07 -0600
Subject: [PATCH 5/8] Tighten [DeduplicatedWithResponse] IResult support after
review
- Replay a Location the endpoint set on the response when the result has none of its own
- Refuse at startup an IResult interface that no replayable result implements
- Recognize the IResult writer by the resource type rather than the writer frame's shape
- Resolve the logger only on the error paths
- Name every replayable result in the startup refusal
- Test Accepted with a Location, and keep the union's failure arm from doing work in the Marten test
---
.../deduplicated_with_response_on_marten.cs | 9 ++++---
.../deduplicated_with_response.cs | 25 +++++++++++++++++++
.../deduplicated_with_response_declaration.cs | 25 ++++++++++++++++++-
.../HttpChain.DeduplicatedWithResponse.cs | 5 ++--
src/Http/Wolverine.Http/HttpHandler.cs | 23 +++++++++++------
5 files changed, 72 insertions(+), 15 deletions(-)
diff --git a/src/Http/Wolverine.Http.Tests/Marten/deduplicated_with_response_on_marten.cs b/src/Http/Wolverine.Http.Tests/Marten/deduplicated_with_response_on_marten.cs
index 16015b2a2..d810ffef0 100644
--- a/src/Http/Wolverine.Http.Tests/Marten/deduplicated_with_response_on_marten.cs
+++ b/src/Http/Wolverine.Http.Tests/Marten/deduplicated_with_response_on_marten.cs
@@ -963,10 +963,13 @@ public static DeduplicatedOrderCreated PostOrderByDefault(DeduplicatedOrder requ
public static Results, NotFound> PostResult(DeduplicatedOrder request,
IDocumentSession session)
{
- // Either way the work commits; only the Created answer is claimed
- var created = PostOrder(request, session);
- if (request.Name.StartsWith(Absent)) return TypedResults.NotFound();
+ if (request.Name.StartsWith(Absent))
+ {
+ _runs.AddOrUpdate(request.Name, 1, (_, count) => count + 1);
+ return TypedResults.NotFound();
+ }
+ var created = PostOrder(request, session);
return TypedResults.Created($"/marten-deduplicated-response/orders/{created.Id}", created);
}
diff --git a/src/Http/Wolverine.Http.Tests/deduplicated_with_response.cs b/src/Http/Wolverine.Http.Tests/deduplicated_with_response.cs
index d8c833619..f490d8af1 100644
--- a/src/Http/Wolverine.Http.Tests/deduplicated_with_response.cs
+++ b/src/Http/Wolverine.Http.Tests/deduplicated_with_response.cs
@@ -389,6 +389,23 @@ public async Task a_no_content_result_is_replayed_with_no_body()
DeduplicatedResponseEndpoints.Runs(request.Name).ShouldBe(1);
}
+ [Fact]
+ public async Task an_accepted_result_is_replayed_with_its_location()
+ {
+ var key = Guid.NewGuid().ToString();
+ var request = new DeduplicatedOrder("accepted-result");
+
+ var first = await postAsync(request, key, 202, url: "/deduplicated-response/accepted-result");
+ var repeat = await postAsync(request, key, 202, url: "/deduplicated-response/accepted-result");
+
+ (await repeat.ReadAsTextAsync()).ShouldBe(await first.ReadAsTextAsync());
+ var location = first.Context.Response.Headers.Location.ToString();
+ location.ShouldStartWith("/deduplicated-response/orders/");
+ repeat.Context.Response.Headers.Location.ToString().ShouldBe(location);
+
+ DeduplicatedResponseEndpoints.Runs(request.Name).ShouldBe(1);
+ }
+
[Fact]
public async Task the_success_arm_of_a_result_union_is_replayed()
{
@@ -695,6 +712,14 @@ public static NoContent PostNoContentResult(DeduplicatedOrder request)
return TypedResults.NoContent();
}
+ [DeduplicatedWithResponse(DeduplicationScope.User)]
+ [WolverinePost("/deduplicated-response/accepted-result")]
+ public static Accepted PostAcceptedResult(DeduplicatedOrder request)
+ {
+ var created = run(request);
+ return TypedResults.Accepted($"/deduplicated-response/orders/{created.Id}", created);
+ }
+
[DeduplicatedWithResponse(DeduplicationScope.User)]
[WolverinePost("/deduplicated-response/union-result")]
public static Results, NotFound> PostUnionResult(DeduplicatedOrder request)
diff --git a/src/Http/Wolverine.Http.Tests/deduplicated_with_response_declaration.cs b/src/Http/Wolverine.Http.Tests/deduplicated_with_response_declaration.cs
index 0a9ef0812..53eb74682 100644
--- a/src/Http/Wolverine.Http.Tests/deduplicated_with_response_declaration.cs
+++ b/src/Http/Wolverine.Http.Tests/deduplicated_with_response_declaration.cs
@@ -53,6 +53,14 @@ public void a_result_that_is_never_written_as_json_is_refused_at_startup()
.Message.ShouldContain(nameof(FileContentHttpResult));
}
+ [Fact]
+ public void a_result_interface_no_replayable_result_implements_is_refused_at_startup()
+ {
+ Should.Throw(() =>
+ HttpChain.ChainFor(typeof(ContentInterfaceDeduplicatedResponse), "Post"))
+ .Message.ShouldContain(nameof(ITextResult));
+ }
+
[Fact]
public void a_result_union_with_a_replayable_arm_is_not_refused()
{
@@ -336,7 +344,7 @@ public void Log(LogLevel logLevel, EventId eventId, TState state, Except
}
}
-// The five below are invalid on purpose. Hidden from discovery, so no other host scanning this assembly fails
+// The six below are invalid on purpose. Hidden from discovery, so no other host scanning this assembly fails
// to start; the tests build their chains directly.
[WolverineIgnore]
@@ -374,6 +382,21 @@ public static class FileResultDeduplicatedResponse
public static FileContentHttpResult Post(DeduplicatedOrder request) => TypedResults.File([1, 2, 3]);
}
+[WolverineIgnore]
+public static class ContentInterfaceDeduplicatedResponse
+{
+ [DeduplicatedWithResponse(DeduplicationScope.User)]
+ [WolverinePost("/declaration/content-result")]
+ public static ITextResult Post(DeduplicatedOrder request) => new TextResult(request.Name);
+}
+
+public interface ITextResult : IResult;
+
+public record TextResult(string Text) : ITextResult
+{
+ public Task ExecuteAsync(HttpContext httpContext) => httpContext.Response.WriteAsync(Text);
+}
+
[WolverineIgnore]
public static class NegativeWindowDeduplicatedResponse
{
diff --git a/src/Http/Wolverine.Http/HttpChain.DeduplicatedWithResponse.cs b/src/Http/Wolverine.Http/HttpChain.DeduplicatedWithResponse.cs
index 0c34452dd..f9cb021fc 100644
--- a/src/Http/Wolverine.Http/HttpChain.DeduplicatedWithResponse.cs
+++ b/src/Http/Wolverine.Http/HttpChain.DeduplicatedWithResponse.cs
@@ -98,7 +98,7 @@ private void assertDeduplicatedWithResponseIsValid(DeduplicatedWithResponseRequi
if (typeof(IResult).IsAssignableFrom(ResourceType) && !HttpHandler.CanReplayResultOf(ResourceType!))
{
throw new NotSupportedException(
- $"{Description} has [DeduplicatedWithResponse], which replays the response as its status, System.Text.Json body and Location, but this endpoint returns {ResourceType!.FullNameInCode()}, which writes its response another way. Return Ok, Created, Accepted or NoContent results, or a resource written as JSON. See GH-4742");
+ $"{Description} has [DeduplicatedWithResponse], which replays the response as its status, System.Text.Json body and Location, but this endpoint returns {ResourceType!.FullNameInCode()}, which writes its response another way. Return Ok, Created, Accepted, NoContent, StatusCode or Empty results, or a resource written as JSON. See GH-4742");
}
if (BindsFormValues)
@@ -298,8 +298,7 @@ private void assertDeduplicatedWithResponseIsJson()
// The stored body is System.Text.Json; any other writer would replay different bytes. An IResult is
// described per request by HttpHandler.CompleteDeduplicatedRequest, which stores only the results it can.
if (DeduplicatedWithResponse != null && !Postprocessors.OfType().Any() &&
- !Postprocessors.OfType().Any(x =>
- x.Method.DeclaringType == typeof(IResult) && x.Method.Name == nameof(IResult.ExecuteAsync)))
+ !typeof(IResult).IsAssignableFrom(ResourceType))
{
throw new NotSupportedException(
$"{Description} has [DeduplicatedWithResponse], which replays the response as System.Text.Json, but this endpoint writes its response another way. See GH-4742");
diff --git a/src/Http/Wolverine.Http/HttpHandler.cs b/src/Http/Wolverine.Http/HttpHandler.cs
index 5d021e4ed..428cc0838 100644
--- a/src/Http/Wolverine.Http/HttpHandler.cs
+++ b/src/Http/Wolverine.Http/HttpHandler.cs
@@ -551,8 +551,8 @@ private static bool isReplayableResult(Type type)
=> _replayableResults.Contains(type.IsGenericType ? type.GetGenericTypeDefinition() : type);
///
- /// Can a declared ever be a result [DeduplicatedWithResponse] can store? An
- /// interface, or a nested result other than Results<...> , is only known per request.
+ /// Can a declared ever be a stored result? An interface a replayable result
+ /// implements, or a nested result other than Results<...> , is decided per request.
///
internal static bool CanReplayResultOf(Type resultType)
{
@@ -561,8 +561,14 @@ internal static bool CanReplayResultOf(Type resultType)
return resultType.GetGenericArguments().Any(CanReplayResultOf);
}
- return resultType.IsInterface || typeof(INestedHttpResult).IsAssignableFrom(resultType) ||
- isReplayableResult(resultType);
+ if (resultType.IsInterface)
+ {
+ var definition = resultType.IsGenericType ? resultType.GetGenericTypeDefinition() : resultType;
+ return _replayableResults.Any(replayable => replayable.GetInterfaces().Any(implemented =>
+ (implemented.IsGenericType ? implemented.GetGenericTypeDefinition() : implemented) == definition));
+ }
+
+ return typeof(INestedHttpResult).IsAssignableFrom(resultType) || isReplayableResult(resultType);
}
///
@@ -579,13 +585,12 @@ internal static bool CanReplayResultOf(Type resultType)
context.Items[_deduplicatedWorkDone] = true;
- var logger = context.RequestServices.GetService>();
var type = result.GetType();
if (!isReplayableResult(type))
{
// The work is done, so the claim stays: repeats get 409 until it expires rather than running it again.
- logger?.LogError(
+ context.RequestServices.GetService>()?.LogError(
"The {ResultType} answering deduplicated id '{DeduplicationId}' is not stored, because it writes more than a status, a JSON body and a Location",
type.FullNameInCode(), deduplicationId);
return null;
@@ -593,8 +598,10 @@ internal static bool CanReplayResultOf(Type resultType)
try
{
- // Created and Accepted, with or without a value; ExecuteAsync sets the header only when it is not empty
+ // Created and Accepted, with or without a value; ExecuteAsync sets the header only when it is not empty.
+ // Any other result leaves whatever Location the endpoint set on the response.
var location = type.GetProperty(nameof(Created.Location))?.GetValue(result) as string;
+ if (string.IsNullOrEmpty(location)) location = context.Response.Headers.Location.ToString();
return new DeduplicatedResponse(status,
result is IValueHttpResult { Value: { } value } ? serializeAsResultWrites(context, type, value) : null,
@@ -602,7 +609,7 @@ internal static bool CanReplayResultOf(Type resultType)
}
catch (Exception e)
{
- logger?.LogError(e,
+ context.RequestServices.GetService>()?.LogError(e,
"Failed to serialize the response for deduplicated id '{DeduplicationId}' to store it", deduplicationId);
return null;
}
From 8a995dc2da9d5485500c24bd5a01c2fe6d3d017d Mon Sep 17 00:00:00 2001
From: Eric Sondergard <4650644+esond@users.noreply.github.com>
Date: Wed, 4 Nov 2026 22:16:35 -0600
Subject: [PATCH 6/8] Drop comments the IResult support repeated from its code
---
.../deduplicated_with_response.cs | 3 ---
.../deduplicated_with_response_declaration.cs | 2 +-
.../HttpChain.DeduplicatedWithResponse.cs | 3 +--
src/Http/Wolverine.Http/HttpHandler.cs | 13 +------------
4 files changed, 3 insertions(+), 18 deletions(-)
diff --git a/src/Http/Wolverine.Http.Tests/deduplicated_with_response.cs b/src/Http/Wolverine.Http.Tests/deduplicated_with_response.cs
index f490d8af1..97fd55298 100644
--- a/src/Http/Wolverine.Http.Tests/deduplicated_with_response.cs
+++ b/src/Http/Wolverine.Http.Tests/deduplicated_with_response.cs
@@ -361,7 +361,6 @@ public async Task a_created_result_is_replayed_with_its_status_body_and_location
[Fact]
public async Task an_ok_result_is_replayed_as_its_value_was_written()
{
- // The declared value type is a base record; the result writes the derived one's members, so must the replay
var key = Guid.NewGuid().ToString();
var request = new DeduplicatedOrder("ok-result");
@@ -437,8 +436,6 @@ public async Task the_failure_arm_of_a_result_union_releases_the_claim()
[Fact]
public async Task a_result_that_cannot_be_stored_keeps_the_claim_and_refuses_repeats()
{
- // A text result writes text/plain, which a replay cannot reproduce. The work was done, so it is not run
- // again, and there is no response to answer with
var key = Guid.NewGuid().ToString();
var request = new DeduplicatedOrder("text-result");
diff --git a/src/Http/Wolverine.Http.Tests/deduplicated_with_response_declaration.cs b/src/Http/Wolverine.Http.Tests/deduplicated_with_response_declaration.cs
index 53eb74682..fbed5efb7 100644
--- a/src/Http/Wolverine.Http.Tests/deduplicated_with_response_declaration.cs
+++ b/src/Http/Wolverine.Http.Tests/deduplicated_with_response_declaration.cs
@@ -344,7 +344,7 @@ public void Log(LogLevel logLevel, EventId eventId, TState state, Except
}
}
-// The six below are invalid on purpose. Hidden from discovery, so no other host scanning this assembly fails
+// The ones below are invalid on purpose. Hidden from discovery, so no other host scanning this assembly fails
// to start; the tests build their chains directly.
[WolverineIgnore]
diff --git a/src/Http/Wolverine.Http/HttpChain.DeduplicatedWithResponse.cs b/src/Http/Wolverine.Http/HttpChain.DeduplicatedWithResponse.cs
index f9cb021fc..274168b5b 100644
--- a/src/Http/Wolverine.Http/HttpChain.DeduplicatedWithResponse.cs
+++ b/src/Http/Wolverine.Http/HttpChain.DeduplicatedWithResponse.cs
@@ -295,8 +295,7 @@ private void warnClaimInTransactionFallsBack(IServiceContainer container, bool a
/// Called from DetermineFrames once the response writer is chosen.
private void assertDeduplicatedWithResponseIsJson()
{
- // The stored body is System.Text.Json; any other writer would replay different bytes. An IResult is
- // described per request by HttpHandler.CompleteDeduplicatedRequest, which stores only the results it can.
+ // The stored body is System.Text.Json; any other writer would replay different bytes.
if (DeduplicatedWithResponse != null && !Postprocessors.OfType().Any() &&
!typeof(IResult).IsAssignableFrom(ResourceType))
{
diff --git a/src/Http/Wolverine.Http/HttpHandler.cs b/src/Http/Wolverine.Http/HttpHandler.cs
index 428cc0838..bdf9311c2 100644
--- a/src/Http/Wolverine.Http/HttpHandler.cs
+++ b/src/Http/Wolverine.Http/HttpHandler.cs
@@ -537,7 +537,6 @@ public static bool IsDeduplicatedResponseCommitted(HttpContext context)
}
}
- // The results whose whole response is their status, JSON value and Location, so a replay writes the same bytes
private static readonly HashSet _replayableResults =
[
typeof(Ok), typeof(Ok<>), typeof(Created), typeof(Created<>), typeof(Accepted), typeof(Accepted<>),
@@ -550,10 +549,6 @@ public static bool IsDeduplicatedResponseCommitted(HttpContext context)
private static bool isReplayableResult(Type type)
=> _replayableResults.Contains(type.IsGenericType ? type.GetGenericTypeDefinition() : type);
- ///
- /// Can a declared ever be a stored result? An interface a replayable result
- /// implements, or a nested result other than Results<...> , is decided per request.
- ///
internal static bool CanReplayResultOf(Type resultType)
{
if (resultType.IsGenericType && _resultUnions.Contains(resultType.GetGenericTypeDefinition()))
@@ -571,10 +566,6 @@ internal static bool CanReplayResultOf(Type resultType)
return typeof(INestedHttpResult).IsAssignableFrom(resultType) || isReplayableResult(resultType);
}
- ///
- /// An has not executed yet, so its response is described from the result, as its
- /// ExecuteAsync will write it.
- ///
private static DeduplicatedResponse? completeDeduplicatedResult(HttpContext context, string deduplicationId,
IResult result)
{
@@ -589,7 +580,6 @@ internal static bool CanReplayResultOf(Type resultType)
if (!isReplayableResult(type))
{
- // The work is done, so the claim stays: repeats get 409 until it expires rather than running it again.
context.RequestServices.GetService>()?.LogError(
"The {ResultType} answering deduplicated id '{DeduplicationId}' is not stored, because it writes more than a status, a JSON body and a Location",
type.FullNameInCode(), deduplicationId);
@@ -598,8 +588,7 @@ internal static bool CanReplayResultOf(Type resultType)
try
{
- // Created and Accepted, with or without a value; ExecuteAsync sets the header only when it is not empty.
- // Any other result leaves whatever Location the endpoint set on the response.
+ // Created.ExecuteAsync sets the header only when Location is not empty.
var location = type.GetProperty(nameof(Created.Location))?.GetValue(result) as string;
if (string.IsNullOrEmpty(location)) location = context.Response.Headers.Location.ToString();
From 080d5e09abe202da7e280689ceb737536325198b Mon Sep 17 00:00:00 2001
From: Eric Sondergard <4650644+esond@users.noreply.github.com>
Date: Wed, 4 Nov 2026 22:32:33 -0600
Subject: [PATCH 7/8] Describe an IResult only when the endpoint declares one,
and unwrap only Results unions
- An endpoint declared as object that returns a typed result is written as JSON by the resource writer, so its
response is stored the same way rather than as the result's status and value
- Only ASP.NET Core's Results<...> unions are unwrapped. A custom INestedHttpResult may write more than the result it
holds, so it is refused at startup when declared and kept unanswered when returned
---
.../deduplicated_with_response.cs | 24 +++++++++++++++++++
.../deduplicated_with_response_declaration.cs | 24 +++++++++++++++++++
src/Http/Wolverine.Http/HttpHandler.cs | 16 +++++++++----
3 files changed, 60 insertions(+), 4 deletions(-)
diff --git a/src/Http/Wolverine.Http.Tests/deduplicated_with_response.cs b/src/Http/Wolverine.Http.Tests/deduplicated_with_response.cs
index 97fd55298..fae4009d7 100644
--- a/src/Http/Wolverine.Http.Tests/deduplicated_with_response.cs
+++ b/src/Http/Wolverine.Http.Tests/deduplicated_with_response.cs
@@ -375,6 +375,22 @@ public async Task an_ok_result_is_replayed_as_its_value_was_written()
DeduplicatedResponseEndpoints.Runs(request.Name).ShouldBe(1);
}
+ [Fact]
+ public async Task a_result_declared_as_object_is_replayed_as_the_json_it_was_written_as()
+ {
+ var key = Guid.NewGuid().ToString();
+ var request = new DeduplicatedOrder("object-result");
+
+ var first = await postAsync(request, key, 200, url: "/deduplicated-response/object-result");
+ var repeat = await postAsync(request, key, 200, url: "/deduplicated-response/object-result");
+
+ var body = await first.ReadAsTextAsync();
+ body.ShouldContain("\"location\"");
+ (await repeat.ReadAsTextAsync()).ShouldBe(body);
+
+ DeduplicatedResponseEndpoints.Runs(request.Name).ShouldBe(1);
+ }
+
[Fact]
public async Task a_no_content_result_is_replayed_with_no_body()
{
@@ -701,6 +717,14 @@ public static Ok PostOkResult(DeduplicatedOrder request)
return TypedResults.Ok(new DetailedDeduplicatedAnswer(request.Name, Guid.NewGuid().ToString()));
}
+ [DeduplicatedWithResponse(DeduplicationScope.User)]
+ [WolverinePost("/deduplicated-response/object-result")]
+ public static object PostObjectResult(DeduplicatedOrder request)
+ {
+ var created = run(request);
+ return TypedResults.Created($"/deduplicated-response/orders/{created.Id}", created);
+ }
+
[DeduplicatedWithResponse(DeduplicationScope.User)]
[WolverinePost("/deduplicated-response/no-content-result")]
public static NoContent PostNoContentResult(DeduplicatedOrder request)
diff --git a/src/Http/Wolverine.Http.Tests/deduplicated_with_response_declaration.cs b/src/Http/Wolverine.Http.Tests/deduplicated_with_response_declaration.cs
index fbed5efb7..b2cf356f6 100644
--- a/src/Http/Wolverine.Http.Tests/deduplicated_with_response_declaration.cs
+++ b/src/Http/Wolverine.Http.Tests/deduplicated_with_response_declaration.cs
@@ -61,6 +61,14 @@ public void a_result_interface_no_replayable_result_implements_is_refused_at_sta
.Message.ShouldContain(nameof(ITextResult));
}
+ [Fact]
+ public void a_custom_nested_result_is_refused_at_startup()
+ {
+ Should.Throw(() =>
+ HttpChain.ChainFor(typeof(EnvelopeResultDeduplicatedResponse), "Post"))
+ .Message.ShouldContain(nameof(EnvelopeResult));
+ }
+
[Fact]
public void a_result_union_with_a_replayable_arm_is_not_refused()
{
@@ -397,6 +405,22 @@ public record TextResult(string Text) : ITextResult
public Task ExecuteAsync(HttpContext httpContext) => httpContext.Response.WriteAsync(Text);
}
+[WolverineIgnore]
+public static class EnvelopeResultDeduplicatedResponse
+{
+ [DeduplicatedWithResponse(DeduplicationScope.User)]
+ [WolverinePost("/declaration/envelope-result")]
+ public static EnvelopeResult Post(DeduplicatedOrder request)
+ => new(TypedResults.Ok(new DeduplicatedOrderCreated(Guid.NewGuid())));
+}
+
+// Holds an Ok, but writes it inside an envelope, so a replay of the Ok alone would differ
+public record EnvelopeResult(IResult Result) : IResult, INestedHttpResult
+{
+ public Task ExecuteAsync(HttpContext httpContext)
+ => httpContext.Response.WriteAsJsonAsync(new { data = (Result as IValueHttpResult)?.Value });
+}
+
[WolverineIgnore]
public static class NegativeWindowDeduplicatedResponse
{
diff --git a/src/Http/Wolverine.Http/HttpHandler.cs b/src/Http/Wolverine.Http/HttpHandler.cs
index bdf9311c2..7062c3951 100644
--- a/src/Http/Wolverine.Http/HttpHandler.cs
+++ b/src/Http/Wolverine.Http/HttpHandler.cs
@@ -513,7 +513,11 @@ public static bool IsDeduplicatedResponseCommitted(HttpContext context)
{
if (string.IsNullOrWhiteSpace(deduplicationId)) return null;
- if (resource is IResult result) return completeDeduplicatedResult(context, deduplicationId, result);
+ // By the declared type, which picked the response writer: an IResult returned as object is written as JSON
+ if (typeof(IResult).IsAssignableFrom(typeof(T)) && resource is IResult result)
+ {
+ return completeDeduplicatedResult(context, deduplicationId, result);
+ }
var status = resource is null ? missingResourceStatusCode : context.Response.StatusCode;
if (status >= 400) return null;
@@ -549,9 +553,12 @@ public static bool IsDeduplicatedResponseCommitted(HttpContext context)
private static bool isReplayableResult(Type type)
=> _replayableResults.Contains(type.IsGenericType ? type.GetGenericTypeDefinition() : type);
+ private static bool isResultUnion(Type type)
+ => type.IsGenericType && _resultUnions.Contains(type.GetGenericTypeDefinition());
+
internal static bool CanReplayResultOf(Type resultType)
{
- if (resultType.IsGenericType && _resultUnions.Contains(resultType.GetGenericTypeDefinition()))
+ if (isResultUnion(resultType))
{
return resultType.GetGenericArguments().Any(CanReplayResultOf);
}
@@ -563,13 +570,14 @@ internal static bool CanReplayResultOf(Type resultType)
(implemented.IsGenericType ? implemented.GetGenericTypeDefinition() : implemented) == definition));
}
- return typeof(INestedHttpResult).IsAssignableFrom(resultType) || isReplayableResult(resultType);
+ return isReplayableResult(resultType);
}
private static DeduplicatedResponse? completeDeduplicatedResult(HttpContext context, string deduplicationId,
IResult result)
{
- while (result is INestedHttpResult nested) result = nested.Result;
+ // Only ASP.NET Core's unions are known to write exactly what the arm they hold writes
+ while (isResultUnion(result.GetType()) && result is INestedHttpResult nested) result = nested.Result;
var status = (result as IStatusCodeHttpResult)?.StatusCode ?? context.Response.StatusCode;
if (status >= 400 || result is ChallengeHttpResult or ForbidHttpResult) return null;
From c2904dad99c5a46f56916df2c52223243b0ed178 Mon Sep 17 00:00:00 2001
From: Eric Sondergard <4650644+esond@users.noreply.github.com>
Date: Wed, 4 Nov 2026 22:48:40 -0600
Subject: [PATCH 8/8] Exempt an IResult only when the built-in result writer
writes it
A custom IResourceWriterPolicy runs ahead of ResultWriterPolicy and may write an IResult some other way, so the
JSON-only check looks for the IResult.ExecuteAsync writer again rather than the resource type
---
.../deduplicated_with_response.cs | 39 ++++++++++++++++++-
.../HttpChain.DeduplicatedWithResponse.cs | 6 ++-
2 files changed, 42 insertions(+), 3 deletions(-)
diff --git a/src/Http/Wolverine.Http.Tests/deduplicated_with_response.cs b/src/Http/Wolverine.Http.Tests/deduplicated_with_response.cs
index fae4009d7..221f90398 100644
--- a/src/Http/Wolverine.Http.Tests/deduplicated_with_response.cs
+++ b/src/Http/Wolverine.Http.Tests/deduplicated_with_response.cs
@@ -2,6 +2,7 @@
using Alba;
using IntegrationTests;
using JasperFx.CodeGeneration;
+using JasperFx.CodeGeneration.Frames;
using JasperFx.Core;
using JasperFx.Core.Reflection;
using JasperFx.Resources;
@@ -13,6 +14,7 @@
using Microsoft.Extensions.Hosting;
using Npgsql;
using Shouldly;
+using Wolverine.Http.Resources;
using Wolverine.Http.Runtime;
using Wolverine.Postgresql;
using Wolverine.Tracking;
@@ -55,10 +57,13 @@ public async ValueTask InitializeAsync()
// No tenant id detection, deliberately -- see a_tenant_scope_needs_tenant_detection.
theHost = await AlbaHost.For(builder, app => app.MapWolverineEndpoints(opts =>
+ {
+ opts.AddResourceWriterPolicy();
opts.CustomizeHttpEndpointDiscovery(q =>
q.Excludes.WithCondition("Not a [DeduplicatedWithResponse] test endpoint",
type => type != typeof(DeduplicatedResponseEndpoints) && type != typeof(EarlyExitDeduplicatedResponseEndpoint)
- && type != typeof(MissingResourceDeduplicatedEndpoint)))));
+ && type != typeof(MissingResourceDeduplicatedEndpoint)));
+ }));
await ((IHost)theHost).ResetResourceState();
@@ -338,6 +343,14 @@ public void a_response_that_is_not_system_text_json_is_refused()
ex.Message.ShouldContain("System.Text.Json");
}
+ [Fact]
+ public void a_result_a_custom_writer_policy_writes_is_refused()
+ {
+ var ex = Should.Throw(() => compile("/deduplicated-response/custom-written-result"));
+
+ ex.Message.ShouldContain("System.Text.Json");
+ }
+
[Fact]
public async Task a_created_result_is_replayed_with_its_status_body_and_location()
{
@@ -717,6 +730,11 @@ public static Ok PostOkResult(DeduplicatedOrder request)
return TypedResults.Ok(new DetailedDeduplicatedAnswer(request.Name, Guid.NewGuid().ToString()));
}
+ [DeduplicatedWithResponse(DeduplicationScope.User)]
+ [WolverinePost("/deduplicated-response/custom-written-result")]
+ public static Ok PostCustomWrittenResult(DeduplicatedOrder request)
+ => TypedResults.Ok(new CustomWrittenAnswer(request.Name));
+
[DeduplicatedWithResponse(DeduplicationScope.User)]
[WolverinePost("/deduplicated-response/object-result")]
public static object PostObjectResult(DeduplicatedOrder request)
@@ -766,6 +784,25 @@ public static IResult PostTextResult(DeduplicatedOrder request)
public record DeduplicatedAnswer(string Name);
+public record CustomWrittenAnswer(string Name);
+
+// Takes over Ok ahead of the built-in result writer, writing it as plain text
+public class CustomAnswerWriterPolicy : IResourceWriterPolicy
+{
+ public bool TryApply(HttpChain chain)
+ {
+ if (chain.ResourceType != typeof(Ok)) return false;
+
+ var call = new MethodCall(typeof(CustomAnswerWriterPolicy), nameof(Write));
+ call.Arguments[0] = chain.ResourceVariable ?? chain.Method.Creates.First();
+ chain.Postprocessors.Add(call);
+ return true;
+ }
+
+ public static Task Write(Ok result, HttpContext context)
+ => context.Response.WriteAsync(result.Value!.Name);
+}
+
public record DetailedDeduplicatedAnswer(string Name, string Detail) : DeduplicatedAnswer(Name);
// Its own class: the Validate middleware above needs a body.
diff --git a/src/Http/Wolverine.Http/HttpChain.DeduplicatedWithResponse.cs b/src/Http/Wolverine.Http/HttpChain.DeduplicatedWithResponse.cs
index 274168b5b..de29c4ef6 100644
--- a/src/Http/Wolverine.Http/HttpChain.DeduplicatedWithResponse.cs
+++ b/src/Http/Wolverine.Http/HttpChain.DeduplicatedWithResponse.cs
@@ -295,9 +295,11 @@ private void warnClaimInTransactionFallsBack(IServiceContainer container, bool a
/// Called from DetermineFrames once the response writer is chosen.
private void assertDeduplicatedWithResponseIsJson()
{
- // The stored body is System.Text.Json; any other writer would replay different bytes.
+ // The stored body is System.Text.Json; any other writer would replay different bytes. An IResult counts only
+ // when ResultWriterPolicy wrote it: a custom IResourceWriterPolicy runs first and may write it another way.
if (DeduplicatedWithResponse != null && !Postprocessors.OfType().Any() &&
- !typeof(IResult).IsAssignableFrom(ResourceType))
+ !Postprocessors.OfType().Any(x =>
+ x.Method.DeclaringType == typeof(IResult) && x.Method.Name == nameof(IResult.ExecuteAsync)))
{
throw new NotSupportedException(
$"{Description} has [DeduplicatedWithResponse], which replays the response as System.Text.Json, but this endpoint writes its response another way. See GH-4742");