Dapr .NET SDK: 1.18.4 (Dapr.Workflow).
**Related runtime issue: dapr runtime 10226
Expected Behavior
A caller of DaprWorkflowClient.GetWorkflowStateAsync (or WorkflowClient.GetWorkflowMetadataAsync) should be able to tell "the workflow does not exist" apart from "a transient/recoverable error occurred while reading its state".
- Genuine not-found →
null / WorkflowState.Exists == false.
- Transient error (e.g. gRPC
Unknown, Unavailable, DeadlineExceeded, cancellation) → surfaced distinctly (thrown, or a result that signals "error, not absence") so the caller can retry instead of concluding the workflow is gone.
Actual Behavior
Both methods collapse every error into the same null / Exists == false result that also means "not found", so the two are indistinguishable.
WorkflowGrpcClient.GetWorkflowMetadataAsync — the final catch (Exception) turns any non-NotFound failure into null:
try
{
var response = await grpcClient.GetInstanceAsync(request, grpcCallOptions);
if (response is null) { logger.LogGetWorkflowMetadataInstanceNotFound(instanceId); return null; }
if (!response.Exists) { return null; } // genuine not-found
return ProtoConverters.ToWorkflowMetadata(response.WorkflowState, serializer);
}
catch (RpcException ex) when (ex.StatusCode == StatusCode.NotFound)
{
return null; // genuine not-found
}
catch (Exception ex)
{
logger.LogError(ex, "Error getting workflow metadata for instance '{InstanceId}'", instanceId);
return null; // ← ANY transient/unknown error → same null
}
DaprWorkflowClient.GetWorkflowStateAsync then wraps that (and its own RpcException) into a WorkflowState, and its XML doc explicitly conflates the two cases:
/// <returns>
/// A WorkflowState if the workflow instance exists, or null if the instance does not
/// exist or an error occurs retrieving the metadata.
/// This method never throws.
/// </returns>
public async Task<WorkflowState> GetWorkflowStateAsync(string instanceId, bool getInputsAndOutputs = true, CancellationToken cancellation = default)
{
ArgumentException.ThrowIfNullOrEmpty(instanceId);
try
{
var metadata = await _innerClient.GetWorkflowMetadataAsync(instanceId, getInputsAndOutputs, cancellation);
return new WorkflowState(metadata); // metadata == null => WorkflowState.Exists == false
}
catch (RpcException)
{
return new WorkflowState(null); // error => also Exists == false
}
}
Since WorkflowState.Exists => _metadata is not null, a transient error and a truly-missing instance produce the identical Exists == false, with no exception and no signal. The caller cannot retry intelligently and may treat a still-running workflow as gone.
Notes:
- This is inconsistent with
WaitForWorkflowStartAsync / WaitForWorkflowCompletionAsync, which throw InvalidOperationException when the instance does not exist — so the SDK already distinguishes existence in the wait path, but not in the get-state path.
- Concrete trigger: a companion
dapr/dapr runtime issue 10226 intermittently returns gRPC Unknown (inbox key '…' declared in metadata … but missing from state store) for a running workflow. That transient Unknown hits the catch (Exception) above and is reported to callers as Exists == false, i.e. as if the workflow no longer exists.
Steps to Reproduce the Problem
- Start a workflow and let it run.
- While it is running, make the sidecar return a transient, non-
NotFound error for GetInstance (e.g. reproduce the runtime Unknown/"missing from state store" race, or otherwise induce Unavailable/DeadlineExceeded).
- Call
client.GetWorkflowStateAsync(instanceId).
- Observe: it returns a
WorkflowState with Exists == false — identical to GetWorkflowStateAsync("does-not-exist") — and never throws. There is no way to tell "transient error, retry" from "workflow is gone".
Minimal caller that is forced to guess:
var state = await client.GetWorkflowStateAsync(instanceId); // never throws
if (!state.Exists)
{
// Was the workflow purged/never-created, or did a transient error just occur?
// The SDK gives no way to know — both look the same.
}
Suggested fix: in GetWorkflowMetadataAsync, only map NotFound / !Exists to null; rethrow (or otherwise surface) other RpcExceptions. Correspondingly, let GetWorkflowStateAsync either throw on non-not-found errors or expose the error distinctly, rather than documenting/implementing "never throws → null on any error".
Release Note
RELEASE NOTE: FIX Workflow GetWorkflowStateAsync/GetWorkflowMetadataAsync no longer report transient errors as a missing workflow (Exists == false); not-found and recoverable errors are now distinguishable.
Dapr .NET SDK: 1.18.4 (
Dapr.Workflow).**Related runtime issue: dapr runtime 10226
Expected Behavior
A caller of
DaprWorkflowClient.GetWorkflowStateAsync(orWorkflowClient.GetWorkflowMetadataAsync) should be able to tell "the workflow does not exist" apart from "a transient/recoverable error occurred while reading its state".null/WorkflowState.Exists == false.Unknown,Unavailable,DeadlineExceeded, cancellation) → surfaced distinctly (thrown, or a result that signals "error, not absence") so the caller can retry instead of concluding the workflow is gone.Actual Behavior
Both methods collapse every error into the same
null/Exists == falseresult that also means "not found", so the two are indistinguishable.WorkflowGrpcClient.GetWorkflowMetadataAsync— the finalcatch (Exception)turns any non-NotFoundfailure intonull:DaprWorkflowClient.GetWorkflowStateAsyncthen wraps that (and its ownRpcException) into aWorkflowState, and its XML doc explicitly conflates the two cases:Since
WorkflowState.Exists => _metadata is not null, a transient error and a truly-missing instance produce the identicalExists == false, with no exception and no signal. The caller cannot retry intelligently and may treat a still-running workflow as gone.Notes:
WaitForWorkflowStartAsync/WaitForWorkflowCompletionAsync, which throwInvalidOperationExceptionwhen the instance does not exist — so the SDK already distinguishes existence in the wait path, but not in the get-state path.dapr/daprruntime issue 10226 intermittently returns gRPCUnknown(inbox key '…' declared in metadata … but missing from state store) for a running workflow. That transientUnknownhits thecatch (Exception)above and is reported to callers asExists == false, i.e. as if the workflow no longer exists.Steps to Reproduce the Problem
NotFounderror forGetInstance(e.g. reproduce the runtimeUnknown/"missing from state store" race, or otherwise induceUnavailable/DeadlineExceeded).client.GetWorkflowStateAsync(instanceId).WorkflowStatewithExists == false— identical toGetWorkflowStateAsync("does-not-exist")— and never throws. There is no way to tell "transient error, retry" from "workflow is gone".Minimal caller that is forced to guess:
Suggested fix: in
GetWorkflowMetadataAsync, only mapNotFound/!Existstonull; rethrow (or otherwise surface) otherRpcExceptions. Correspondingly, letGetWorkflowStateAsynceither throw on non-not-found errors or expose the error distinctly, rather than documenting/implementing "never throws → null on any error".Release Note
RELEASE NOTE: FIX Workflow
GetWorkflowStateAsync/GetWorkflowMetadataAsyncno longer report transient errors as a missing workflow (Exists == false); not-found and recoverable errors are now distinguishable.