From c2ef1bca6cea4aa4e837e008b1ea11e4a8b42617 Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Mon, 13 Jul 2026 09:14:18 -0500 Subject: [PATCH] Actionable diagnostic for a header-identified saga over gRPC (GH-3385) A header-identified saga cannot work over a gRPC hop: the saga-id envelope header is not propagated across the call, so no id can be resolved. That is unchanged and is not what this fixes. What it fixes is the diagnostic. The caller used to get StatusCode.Internal with "Could not determine a valid saga state id for Envelope ..." -- true, but it names no cause and no remedy, and the remedy is not guessable, because the reason the id is missing is a property of the transport rather than of the message. It now maps to StatusCode.InvalidArgument with a detail that says the identity must be on the message body, why (the header does not cross the hop), how to supply it ([SagaIdentity] or a matching property), and where to read more. InvalidArgument rather than Internal is deliberate per AIP-193: the request cannot succeed as sent and retrying will not change that -- it is a caller-side contract problem, not a transient server fault. The mapping lives in WolverineGrpcExceptionMapper, which only runs at the gRPC boundary, so the core exception is untouched and non-gRPC users see no change. Message-identified sagas over gRPC already work and are unaffected (covered by #3386). Per @erikshafer's analysis on the issue, full three-point saga-id propagation is deliberately NOT taken: HTTP does not exercise the header path either, so "works like HTTP" is already satisfied, and the complexity is not warranted until someone shows a concrete need. Flips the #3386 characterization test, and updates the gRPC + Sagas docs page. Full Wolverine.Grpc.Tests 273/273. Full wolverine.slnx Release build clean. Closes #3385. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/guide/grpc/sagas.md | 25 +++++++++++++----- .../SagaOverGrpc/saga_over_grpc_tests.cs | 19 +++++++++----- .../WolverineGrpcExceptionMapper.cs | 26 ++++++++++++++++++- 3 files changed, 56 insertions(+), 14 deletions(-) diff --git a/docs/guide/grpc/sagas.md b/docs/guide/grpc/sagas.md index c077d0eb9..b26fca378 100644 --- a/docs/guide/grpc/sagas.md +++ b/docs/guide/grpc/sagas.md @@ -176,13 +176,24 @@ Wolverine has two ways to resolve which saga state a message belongs to: 2. **Header-identified** — the message carries no identity member, and Wolverine falls back to the envelope's `saga-id` header. -**Only message-identified sagas are supported over a gRPC service hop today.** The `saga-id` -envelope header does not cross the hop — neither the client nor server propagation interceptor -carries it, so a header-identified saga invoked through a gRPC service fails with an -`IndeterminateSagaStateIdException` (surfaced to the caller as a `StatusCode.Internal` -`RpcException`). This is tracked in -[GH-3385](https://github.com/JasperFx/wolverine/issues/3385); the practical guidance is simple and -is the better design anyway: +**Only message-identified sagas are supported over a gRPC service hop.** The `saga-id` envelope +header does not cross the hop — neither the client nor server propagation interceptor carries it — +so a header-identified saga invoked through a gRPC service cannot resolve an id at all. + +As of 6.18.0 that fails with an explicit diagnostic rather than an opaque one: the caller gets a +`StatusCode.InvalidArgument` `RpcException` whose detail names both the cause and the fix. + +> Could not determine a saga id for this request. A saga started or continued over a gRPC hop must +> carry its identity ON THE MESSAGE BODY: the 'saga-id' envelope header is not propagated across a +> gRPC call, so a header-identified saga cannot work over gRPC. Put the saga identity on the request +> message itself (a property Wolverine can match to the saga id, or one marked with +> `[SagaIdentity]`). + +`InvalidArgument` rather than `Internal` is deliberate ([AIP-193](https://google.aip.dev/193)): the +request cannot succeed as sent, and no amount of retrying will change that — it is a contract +problem, not a transient server fault. + +The practical guidance is simple, and is the better design anyway: ::: tip Put the saga identity on the request DTO. It makes the contract self-describing for non-Wolverine diff --git a/src/Wolverine.Grpc.Tests/SagaOverGrpc/saga_over_grpc_tests.cs b/src/Wolverine.Grpc.Tests/SagaOverGrpc/saga_over_grpc_tests.cs index 94a37dc57..967ad10cd 100644 --- a/src/Wolverine.Grpc.Tests/SagaOverGrpc/saga_over_grpc_tests.cs +++ b/src/Wolverine.Grpc.Tests/SagaOverGrpc/saga_over_grpc_tests.cs @@ -60,17 +60,24 @@ public async Task can_start_and_continue_a_message_identified_saga_over_grpc() #endregion [Fact] - public async Task starting_a_header_identified_saga_over_grpc_fails_with_opaque_status_today() + public async Task starting_a_header_identified_saga_over_grpc_fails_with_an_actionable_diagnostic() { var client = _fixture.CreateClient(); var ex = await Should.ThrowAsync(async () => await client.Start(new StartCountingRequest { Label = "first" })); - // CHARACTERIZATION of current behavior (GH-3385). saga-id is not propagated across a gRPC - // hop, so PullSagaIdFromEnvelopeFrame throws IndeterminateSagaStateIdException, which maps to - // Internal with a message that doesn't tell the developer what to do about it. - ex.StatusCode.ShouldBe(StatusCode.Internal); - ex.Status.Detail.ShouldContain("saga state id"); + // GH-3385. A header-identified saga still cannot work over a gRPC hop — the 'saga-id' header + // is not propagated across the call, so no id can be resolved. That is a caller-side contract + // problem, not a transient server fault, so it is InvalidArgument (AIP-193) rather than the + // Internal it used to report... + ex.StatusCode.ShouldBe(StatusCode.InvalidArgument); + + // ...and the detail now names the cause AND the remedy, instead of the bare + // "Could not determine a valid saga state id for Envelope ..." that told the developer + // nothing they could act on. + ex.Status.Detail.ShouldContain("ON THE MESSAGE BODY"); + ex.Status.Detail.ShouldContain("[SagaIdentity]"); + ex.Status.Detail.ShouldContain("wolverinefx.net/guide/grpc/sagas"); } } diff --git a/src/Wolverine.Grpc/WolverineGrpcExceptionMapper.cs b/src/Wolverine.Grpc/WolverineGrpcExceptionMapper.cs index e863021a3..27ee0dd4a 100644 --- a/src/Wolverine.Grpc/WolverineGrpcExceptionMapper.cs +++ b/src/Wolverine.Grpc/WolverineGrpcExceptionMapper.cs @@ -1,4 +1,5 @@ using Grpc.Core; +using Wolverine.Persistence.Sagas; namespace Wolverine.Grpc; @@ -33,6 +34,14 @@ public static StatusCode Map(Exception exception) RpcException rpc => rpc.StatusCode, OperationCanceledException => StatusCode.Cancelled, TimeoutException => StatusCode.DeadlineExceeded, + + // GH-3385. The request reached a saga handler that identifies its saga from the envelope + // (a `saga-id` header or [SagaIdentity] on a header), and no saga id could be resolved. + // Over a gRPC hop that is not a transient server fault — it is a contract problem that + // the caller cannot fix by retrying, because the header never crosses the hop at all. + // InvalidArgument per AIP-193, with a detail that says what to do about it. + IndeterminateSagaStateIdException => StatusCode.InvalidArgument, + ArgumentException => StatusCode.InvalidArgument, KeyNotFoundException => StatusCode.NotFound, FileNotFoundException => StatusCode.NotFound, @@ -59,10 +68,25 @@ public static RpcException ToRpcException(Exception exception) return existing; } - var status = new Status(Map(exception), exception.Message); + var status = new Status(Map(exception), detailFor(exception)); return new RpcException(status); } + // GH-3385: the core exception's message ("Could not determine a valid saga state id for + // Envelope ...") is true but useless to someone calling a gRPC service — it names no cause and + // no remedy, and the remedy is not obvious, because the reason the id is missing is that the + // saga-id header does not cross a gRPC hop at all. Say that, and say what to do instead. + private static string detailFor(Exception exception) + { + if (exception is IndeterminateSagaStateIdException) + { + return + "Could not determine a saga id for this request. A saga started or continued over a gRPC hop must carry its identity ON THE MESSAGE BODY: the 'saga-id' envelope header is not propagated across a gRPC call, so a header-identified saga cannot work over gRPC. Put the saga identity on the request message itself (a property Wolverine can match to the saga id, or one marked with [SagaIdentity]). See https://wolverinefx.net/guide/grpc/sagas.html"; + } + + return exception.Message; + } + /// /// The inverse of — maps an incoming /// back to an idiomatic .NET exception type for client-side consumers. Applied by the