Files
tasks/common/nexus/nexusrpc/failure_conveter_test.go
Roey BermanandGitHub b71e299d7a Overhaul Nexus error model (#9290)
## What changed?

Replace the limited Nexus HandlerError proto with full Temporal Failure
objects for transmitting errors across Nexus operation boundaries. This
preserves failure chains, stack traces, and typed failure info
(ApplicationFailure, CanceledFailure, etc.) end-to-end.

Care was taken to ensure Temporal->Temporal communication works as
expected as well as support for non-Temporal Nexus implementations (to
maintain support for external endpoints).
This ended up being more refactoring that I had originally anticipated
but I think the unification of error serialization across the codebase
and the more straightforward conversion logic was worth it.
The code was validated across variations of new and old caller and
handler workers and servers using a
[harness](https://github.com/bergundy/nexus-error-compat-tests) that was
built specifically to validate the behavior. The harness was also run
with an SDK setup that encoded failure attributes to confirm failures
are properly transmitted over the different boundaries.

Proto changes:
- Add `failure` field to DispatchNexusTaskResponse carrying a Failure
with NexusHandlerFailureInfo, deprecate `handler_error`
- Add `capabilities` field to nexus Request for feature negotiation

Failure conversion (`common/nexus/failure.go`):
- Rewrite TemporalFailureToNexusFailure and
NexusFailureToTemporalFailure with support for recursive cause chains
and stack traces
- Add special-case handling for NexusHandlerFailureInfo (serialized as
nexus.HandlerError type) and OperationError (mapped to
CanceledFailure/ApplicationFailure based on state)

Nexus SDK layer (`common/nexus/nexusrpc/`):
- Inline the failure converter from the upstream SDK with a custom
FailureConverter interface that round-trips Temporal failure metadata
through Nexus Failure objects
- Add a completion client to abstract away HTTP and provide consistent
failure conversion across all APIs
- Support `unwrap-error` metadata marker so Temporal-to-Temporal calls
can unwrap the OperationError envelope and recover the original cause

Frontend and backend handlers:
- Route responses through new failure path when caller sends
`temporal-nexus-failure-support` header
- Handle both new `failure` and deprecated `handler_error` response
types in nexus_handler.go and nexus_http_handler.go
- Update completion handling in history handler and mutable state to
produce Temporal Failures instead of Nexus HandlerErrors
- Update matching engine to forward the new failure field

Dependencies:
- Bump `nexus-rpc/sdk-go` to pre-release with StackTrace, Cause, and
OriginalFailure fields on Failure and OperationError

## Why?

Part of getting Nexus to GA in all SDKs, this change fixes a couple of
notable issues with errors in Nexus+Temporal applications:
- Nexus SDK errors did not have a way to set an error message, which
made them difficult to use in various languages
- The protocol used a custom failure format that is diverges from proxy
expectations and may result in failures not being encrypted

## Potential risks
- Metric label values for callback outcomes now use Nexus handler error
values instead of HTTP response codes. Alerts that check these outcomes
will need adjustment.
2026-02-12 07:16:17 +00:00

136 lines
4.3 KiB
Go

package nexusrpc
import (
"errors"
"testing"
"github.com/nexus-rpc/sdk-go/nexus"
"github.com/stretchr/testify/require"
)
func TestFailureConverter_GenericError(t *testing.T) {
failure, err := defaultFailureConverter.ErrorToFailure(errors.New("test"))
require.NoError(t, err)
actual, err := defaultFailureConverter.FailureToError(failure)
require.NoError(t, err)
require.Equal(t, &nexus.FailureError{Failure: nexus.Failure{Message: "test"}}, actual)
}
func TestFailureConverter_FailureError(t *testing.T) {
cause := &nexus.FailureError{
Failure: nexus.Failure{Message: "cause"},
}
fe := &nexus.FailureError{
Failure: nexus.Failure{
Message: "foo",
},
Cause: cause,
}
failure, err := defaultFailureConverter.ErrorToFailure(fe)
require.NoError(t, err)
actual, err := defaultFailureConverter.FailureToError(failure)
require.NoError(t, err)
// The serialized failure cause is retained.
fe.Failure.Cause = failure.Cause
require.Equal(t, fe, actual)
// Serialize again and verify the original failure is used.
fe.Cause = errors.New("should be ignored")
failure, err = defaultFailureConverter.ErrorToFailure(fe)
require.NoError(t, err)
actual, err = defaultFailureConverter.FailureToError(failure)
require.NoError(t, err)
// Reset back to the orignal cause before comparing.
fe.Cause = cause
require.Equal(t, fe, actual)
}
func TestFailureConverter_HandlerError(t *testing.T) {
cause := &nexus.FailureError{Failure: nexus.Failure{Message: "cause"}}
he := nexus.NewHandlerErrorf(nexus.HandlerErrorTypeBadRequest, "foo")
he.StackTrace = "stack"
he.Cause = cause
failure, err := defaultFailureConverter.ErrorToFailure(he)
require.NoError(t, err)
// Verify that the original failure is retained.
he.OriginalFailure = &failure
actual, err := defaultFailureConverter.FailureToError(failure)
require.NoError(t, err)
require.Equal(t, he, actual)
// Serialize again and verify the original failure is used.
he.Cause = errors.New("should be ignored")
failure, err = defaultFailureConverter.ErrorToFailure(he)
require.NoError(t, err)
actual, err = defaultFailureConverter.FailureToError(failure)
require.NoError(t, err)
// Reset back to the orignal cause before comparing.
he.Cause = cause
require.Equal(t, he, actual)
}
func TestFailureConverter_HandlerErrorRetryBehavior(t *testing.T) {
he := nexus.NewHandlerErrorf(nexus.HandlerErrorTypeBadRequest, "foo")
he.StackTrace = "stack"
he.RetryBehavior = nexus.HandlerErrorRetryBehaviorRetryable
failure, err := defaultFailureConverter.ErrorToFailure(he)
require.NoError(t, err)
// Verify that the original failure is retained.
he.OriginalFailure = &failure
actual, err := defaultFailureConverter.FailureToError(failure)
require.NoError(t, err)
require.Equal(t, he, actual)
}
func TestFailureConverter_OperationError(t *testing.T) {
cause := &nexus.FailureError{Failure: nexus.Failure{Message: "cause"}}
oe := nexus.NewOperationCanceledErrorf("foo")
oe.StackTrace = "stack"
oe.Cause = cause
failure, err := defaultFailureConverter.ErrorToFailure(oe)
require.NoError(t, err)
// Verify that the original failure is retained.
oe.OriginalFailure = &failure
actual, err := defaultFailureConverter.FailureToError(failure)
require.NoError(t, err)
require.Equal(t, oe, actual)
// Serialize again and verify the original failure is used.
oe.Cause = errors.New("should be ignored")
failure, err = defaultFailureConverter.ErrorToFailure(oe)
require.NoError(t, err)
actual, err = defaultFailureConverter.FailureToError(failure)
require.NoError(t, err)
// Reset back to the orignal cause before comparing.
oe.Cause = cause
require.Equal(t, oe, actual)
}
func TestDefaultFailureConverterArbitraryError(t *testing.T) {
sourceErr := errors.New("test")
conv := defaultFailureConverter
f, err := conv.ErrorToFailure(sourceErr)
require.NoError(t, err)
convErr, err := conv.FailureToError(f)
require.NoError(t, err)
require.Equal(t, sourceErr.Error(), convErr.Error())
}
func TestDefaultFailureConverterFailureError(t *testing.T) {
sourceErr := &nexus.FailureError{
Failure: nexus.Failure{
Message: "test",
Metadata: map[string]string{"key": "value"},
Details: []byte(`"details"`),
},
}
conv := defaultFailureConverter
f, err := conv.ErrorToFailure(sourceErr)
require.NoError(t, err)
convErr, err := conv.FailureToError(f)
require.NoError(t, err)
require.Equal(t, sourceErr, convErr)
}