Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 18 additions & 7 deletions docs/guide/grpc/sagas.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 13 additions & 6 deletions src/Wolverine.Grpc.Tests/SagaOverGrpc/saga_over_grpc_tests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<RpcException>(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");
}
}
26 changes: 25 additions & 1 deletion src/Wolverine.Grpc/WolverineGrpcExceptionMapper.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Grpc.Core;
using Wolverine.Persistence.Sagas;

namespace Wolverine.Grpc;

Expand Down Expand Up @@ -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,
Expand All @@ -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;
}

/// <summary>
/// The inverse of <see cref="Map"/> — maps an incoming <see cref="RpcException"/>
/// back to an idiomatic .NET exception type for client-side consumers. Applied by the
Expand Down
Loading