mirror of
https://github.com/hanzoai/tasks.git
synced 2026-10-04 02:04:03 +00:00
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/...
201 lines
6.1 KiB
Go
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
|
|
}
|