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