Files
zeekay b25e3770dd feat(sdk/temporal): native retry + error surface, zero upstream deps
Implements pkg/sdk/temporal: the call-site surface that base, commerce,
and ta use when migrating off go.temporal.io/sdk/temporal. Stdlib-only
imports; go list -deps confirms zero go.temporal.io/* in the transitive
tree.

retry.go
- RetryPolicy{InitialInterval, BackoffCoefficient, MaximumInterval,
  MaximumAttempts, NonRetryableErrorTypes} — field shape matches the
  existing callers in base/plugins/tasks and commerce/billing/workflows
  so the migration is a pure import swap.
- DefaultRetryPolicy, Normalize (non-mutating), ShouldRetry,
  NextInterval helpers. Exponential backoff is computed in float64
  with early clamp at MaximumInterval; no overflow on large attempt
  counts. Pure: no sleep, no log, no network.

errors.go
- Single concrete error type (*Error) with Message, Code, NonRetryable,
  Details, Cause. errors.Is / errors.As friendly.
- NewError(msg, code, nonRetryable, details...) is the one canonical
  constructor; upstream NewApplicationError maps to it with
  nonRetryable=false. NewErrorWithCause covers the one call site that
  used NewApplicationErrorWithCause.
- NewCanceledError, NewTimeoutError construct non-retryable sentinels.
  Canceled and Timeout are package-level errors.Is targets matched by
  Code so cross-process comparisons (decoded from the wire) work.
- IsApplicationError, IsCanceledError, IsTimeoutError classification
  helpers unwrap through errors.As chains.

serde.go
- Encode/Decode between *Error and the bytes carried in
  ActivityTask.failure / RespondActivityTaskFailedRequest.failure
  (schema/tasks.zap). JSON body inside a versioned envelope
  ({v:1, p:...}); the envelope decouples body format from callers, so
  we swap to native ZAP once zapc codegen lands without breaking
  anything above.
- Fail-secure: nil, empty, malformed, or wrong-version bytes decode to
  a non-retryable DecodeError — never panic, never nil pointer.
- Non-JSON-serialisable details (e.g. channels) degrade to their
  string form so workers can always report a failure.

temporal_test.go
- Table tests for RetryPolicy (defaults, Normalize immutability,
  ShouldRetry, NextInterval backoff/clamp/nil-policy path).
- Error constructors, empty-code default, Error() string contract,
  Unwrap, sentinel match via errors.Is.
- Classification helpers across nil / plain / application / canceled /
  timeout and wrapped forms.
- Encode/Decode round-trip across plain, application, non-retryable,
  canceled, timeout, with-cause, no-details, 3-level nested cause.
- Hostile-input coverage: non-JSON-serialisable details, nil, empty,
  garbage, wrong version, truncated payload.

Passes: go vet ./pkg/sdk/temporal/... and
go test ./pkg/sdk/temporal/...
2026-04-23 10:30:59 -07:00

201 lines
6.1 KiB
Go

package temporal
import (
"errors"
"fmt"
)
// Sentinel Code values carried on the wire in Error.Code.
// They are matched by IsCanceledError / IsTimeoutError and can be
// listed in RetryPolicy.NonRetryableErrorTypes.
const (
// CodeApplication is the default code for an error produced by
// user workflow/activity code via NewError. It carries the
// user-supplied type tag in Error.Code; this constant is used
// only when the caller did not supply one.
CodeApplication = "ApplicationError"
// CodeCanceled marks an error arising from a workflow / activity
// cancellation. Non-retryable by construction.
CodeCanceled = "CanceledError"
// CodeTimeout marks an error arising from schedule-to-close,
// start-to-close, or heartbeat timeouts. Non-retryable.
CodeTimeout = "TimeoutError"
// CodeDecode marks an error produced by the failure decoder
// when given bytes it cannot parse. Non-retryable.
CodeDecode = "DecodeError"
)
// Error is the one and only error type this SDK emits for
// workflow/activity failures. Having a single concrete type keeps
// classification trivial (type assertion + Code check) and
// serialisation obvious.
//
// The zero value is a usable but uninformative error. Prefer the
// constructors below.
type Error struct {
// Message is the human-readable error text. Safe to log.
Message string
// Code is a stable, machine-readable tag. Callers compare
// against this to decide whether to retry, branch, etc. It is
// the field listed in RetryPolicy.NonRetryableErrorTypes.
//
// Reserved values: CodeApplication, CodeCanceled, CodeTimeout,
// CodeDecode. Anything else is user-defined.
Code string
// NonRetryable short-circuits retry regardless of MaximumAttempts.
// Canceled and Timeout errors are always non-retryable.
NonRetryable bool
// Details carries arbitrary user payloads attached at the
// failure site. Must be JSON-serialisable. Decoders surface
// them as []any.
Details []any
// Cause wraps the underlying Go error, if any. errors.Unwrap
// exposes it so errors.Is / errors.As chains work.
Cause error
}
// Error implements the error interface.
func (e *Error) Error() string {
if e == nil {
return "<nil>"
}
code := e.Code
if code == "" {
code = CodeApplication
}
if e.Cause != nil {
return fmt.Sprintf("%s (%s): %s", e.Message, code, e.Cause.Error())
}
return fmt.Sprintf("%s (%s)", e.Message, code)
}
// Unwrap allows errors.Is / errors.As to reach the underlying cause.
func (e *Error) Unwrap() error { return e.Cause }
// Is lets callers match via errors.Is(err, Canceled) etc., using
// the sentinel values declared below.
func (e *Error) Is(target error) bool {
var t *Error
if !errors.As(target, &t) {
return false
}
// Sentinels are compared by Code only; instance identity is
// irrelevant because the real value usually came off the wire.
if t == canceledSentinel || t == timeoutSentinel {
return e != nil && e.Code == t.Code
}
return e == t
}
// NewError constructs an Error. msg is the human-readable message,
// code is the machine-readable type tag (empty => CodeApplication),
// nonRetryable forces retry short-circuit, and details is an
// arbitrary JSON-serialisable payload attached to the failure.
//
// This is the one, canonical constructor. Previous upstream callers
// that used NewApplicationError(msg, code, details...) map to
// NewError(msg, code, false, details...).
func NewError(msg, code string, nonRetryable bool, details ...any) *Error {
if code == "" {
code = CodeApplication
}
return &Error{
Message: msg,
Code: code,
NonRetryable: nonRetryable,
Details: details,
}
}
// NewErrorWithCause is NewError with a wrapped underlying error.
// Equivalent to NewError + manual .Cause assignment; kept for call
// sites that previously used NewApplicationErrorWithCause.
func NewErrorWithCause(msg, code string, cause error, nonRetryable bool, details ...any) *Error {
e := NewError(msg, code, nonRetryable, details...)
e.Cause = cause
return e
}
// NewCanceledError constructs a canceled error. Canceled errors are
// always non-retryable.
func NewCanceledError(details ...any) *Error {
return &Error{
Message: "workflow canceled",
Code: CodeCanceled,
NonRetryable: true,
Details: details,
}
}
// NewTimeoutError constructs a timeout error. Timeout errors are
// always non-retryable.
func NewTimeoutError(msg string, details ...any) *Error {
if msg == "" {
msg = "operation timed out"
}
return &Error{
Message: msg,
Code: CodeTimeout,
NonRetryable: true,
Details: details,
}
}
// canceledSentinel / timeoutSentinel back the Canceled / Timeout
// package-level values used with errors.Is. They carry only the
// Code field so the Is() comparison is code-only.
var (
canceledSentinel = &Error{Code: CodeCanceled, NonRetryable: true}
timeoutSentinel = &Error{Code: CodeTimeout, NonRetryable: true}
// Canceled is the sentinel used with errors.Is to detect a
// canceled-class error anywhere in the chain.
Canceled error = canceledSentinel
// Timeout is the sentinel used with errors.Is to detect a
// timeout-class error anywhere in the chain.
Timeout error = timeoutSentinel
)
// AsError is a typed wrapper around errors.As for *Error.
// Returns the embedded *Error and true on hit, nil and false otherwise.
func AsError(err error) (*Error, bool) {
var t *Error
if errors.As(err, &t) {
return t, true
}
return nil, false
}
// IsApplicationError reports whether err (or anything it wraps) is
// an *Error with an application-class Code — i.e. it was produced
// by user code via NewError, not a canceled or timeout sentinel.
func IsApplicationError(err error) bool {
t, ok := AsError(err)
if !ok {
return false
}
return t.Code != CodeCanceled && t.Code != CodeTimeout
}
// IsCanceledError reports whether err (or anything it wraps) is a
// canceled-class *Error.
func IsCanceledError(err error) bool {
t, ok := AsError(err)
return ok && t.Code == CodeCanceled
}
// IsTimeoutError reports whether err (or anything it wraps) is a
// timeout-class *Error.
func IsTimeoutError(err error) bool {
t, ok := AsError(err)
return ok && t.Code == CodeTimeout
}