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/...
176 lines
5.1 KiB
Go
176 lines
5.1 KiB
Go
package temporal
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
)
|
|
|
|
// failureEnvelopeVersion is the envelope version prefix. Bump only
|
|
// when the wire layout changes incompatibly. The decoder rejects
|
|
// anything with a different version (and returns a DecodeError), so
|
|
// this is how we guarantee forward/backward isolation.
|
|
const failureEnvelopeVersion = 1
|
|
|
|
// failureEnvelope is the on-wire shape carried inside the
|
|
// ActivityTask / RespondActivityTaskFailed `failure Bytes` field.
|
|
//
|
|
// Body is JSON today; the plan (per the package doc) is to swap to
|
|
// native ZAP once zapc codegen lands. The envelope stays the same:
|
|
// { version, payload } bytes. Consumers that see Version != 1
|
|
// return DecodeError — no silent promotion.
|
|
type failureEnvelope struct {
|
|
Version int `json:"v"`
|
|
Payload json.RawMessage `json:"p"`
|
|
}
|
|
|
|
// failurePayload is the inner JSON shape for Version=1. It is a
|
|
// direct mirror of *Error minus the non-serialisable Cause, which
|
|
// is projected into CauseMessage/CauseCode so it round-trips as a
|
|
// plain nested envelope.
|
|
type failurePayload struct {
|
|
Message string `json:"message"`
|
|
Code string `json:"code"`
|
|
NonRetryable bool `json:"nonRetryable,omitempty"`
|
|
Details []json.RawMessage `json:"details,omitempty"`
|
|
Cause *failurePayload `json:"cause,omitempty"`
|
|
}
|
|
|
|
// Encode serialises err into the bytes that live in the `failure`
|
|
// field of RespondActivityTaskFailedRequest (schema/tasks.zap).
|
|
//
|
|
// If err is not already an *Error it is wrapped as an ApplicationError
|
|
// with the original message preserved. Non-JSON-serialisable details
|
|
// are replaced by their error string so Encode never fails on input
|
|
// (workers must not get stuck unable to report a failure).
|
|
func Encode(err error) ([]byte, error) {
|
|
payload := toPayload(err)
|
|
body, merr := json.Marshal(payload)
|
|
if merr != nil {
|
|
// Should be unreachable — sanitiseDetails strips anything
|
|
// that would trip Marshal. If it does happen, emit a
|
|
// well-formed envelope that decodes back to a DecodeError.
|
|
return Encode(NewError(
|
|
fmt.Sprintf("failed to encode failure: %v", merr),
|
|
CodeDecode, true,
|
|
))
|
|
}
|
|
env := failureEnvelope{
|
|
Version: failureEnvelopeVersion,
|
|
Payload: body,
|
|
}
|
|
return json.Marshal(env)
|
|
}
|
|
|
|
// Decode parses bytes produced by Encode back into an *Error.
|
|
//
|
|
// Fail-secure: any parsing problem returns a non-retryable
|
|
// DecodeError whose message identifies the failure class, never a
|
|
// nil pointer and never a panic. Callers can feed hostile bytes.
|
|
func Decode(b []byte) *Error {
|
|
if len(b) == 0 {
|
|
return NewError("empty failure payload", CodeDecode, true)
|
|
}
|
|
var env failureEnvelope
|
|
if err := json.Unmarshal(b, &env); err != nil {
|
|
return NewError(
|
|
fmt.Sprintf("failure envelope unmarshal: %v", err),
|
|
CodeDecode, true,
|
|
)
|
|
}
|
|
if env.Version != failureEnvelopeVersion {
|
|
return NewError(
|
|
fmt.Sprintf("unsupported failure envelope version %d", env.Version),
|
|
CodeDecode, true,
|
|
)
|
|
}
|
|
var payload failurePayload
|
|
if err := json.Unmarshal(env.Payload, &payload); err != nil {
|
|
return NewError(
|
|
fmt.Sprintf("failure payload unmarshal: %v", err),
|
|
CodeDecode, true,
|
|
)
|
|
}
|
|
return fromPayload(&payload)
|
|
}
|
|
|
|
// toPayload converts any Go error into the JSON-serialisable
|
|
// failurePayload form. Non-Error inputs are treated as application
|
|
// errors with the default code; their message is the err.Error().
|
|
func toPayload(err error) *failurePayload {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
t, ok := AsError(err)
|
|
if !ok {
|
|
return &failurePayload{
|
|
Message: err.Error(),
|
|
Code: CodeApplication,
|
|
}
|
|
}
|
|
out := &failurePayload{
|
|
Message: t.Message,
|
|
Code: t.Code,
|
|
NonRetryable: t.NonRetryable,
|
|
Details: sanitiseDetails(t.Details),
|
|
}
|
|
if t.Cause != nil {
|
|
out.Cause = toPayload(t.Cause)
|
|
}
|
|
if out.Code == "" {
|
|
out.Code = CodeApplication
|
|
}
|
|
return out
|
|
}
|
|
|
|
// fromPayload rehydrates an *Error from a failurePayload.
|
|
func fromPayload(p *failurePayload) *Error {
|
|
if p == nil {
|
|
return nil
|
|
}
|
|
e := &Error{
|
|
Message: p.Message,
|
|
Code: p.Code,
|
|
NonRetryable: p.NonRetryable,
|
|
}
|
|
if e.Code == "" {
|
|
e.Code = CodeApplication
|
|
}
|
|
if len(p.Details) > 0 {
|
|
e.Details = make([]any, 0, len(p.Details))
|
|
for _, raw := range p.Details {
|
|
var v any
|
|
if err := json.Unmarshal(raw, &v); err != nil {
|
|
// Preserve the raw bytes as a string so callers
|
|
// still see _something_ — better than dropping.
|
|
e.Details = append(e.Details, string(raw))
|
|
continue
|
|
}
|
|
e.Details = append(e.Details, v)
|
|
}
|
|
}
|
|
if p.Cause != nil {
|
|
e.Cause = fromPayload(p.Cause)
|
|
}
|
|
return e
|
|
}
|
|
|
|
// sanitiseDetails marshals each detail individually so one bad
|
|
// entry cannot poison the whole failure. Entries that fail to
|
|
// encode are replaced with a best-effort string representation.
|
|
func sanitiseDetails(details []any) []json.RawMessage {
|
|
if len(details) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]json.RawMessage, 0, len(details))
|
|
for _, d := range details {
|
|
raw, err := json.Marshal(d)
|
|
if err != nil {
|
|
fallback, _ := json.Marshal(fmt.Sprintf("%v", d))
|
|
out = append(out, fallback)
|
|
continue
|
|
}
|
|
out = append(out, raw)
|
|
}
|
|
return out
|
|
}
|