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

124 lines
3.9 KiB
Go

// Package temporal provides native Hanzo Tasks workflow primitives
// (retry policy, error model, failure serialisation) with zero
// dependency on any go.temporal.io/* package.
//
// This package is consumed directly by user code (workflows,
// activities) and indirectly by the rest of the SDK
// (pkg/sdk/workflow, pkg/sdk/activity, pkg/sdk/client,
// pkg/sdk/worker). It is deliberately tiny and has no imports
// outside the Go standard library.
package temporal
import "time"
// RetryPolicy controls how the engine retries a workflow or
// activity that returns an error. Semantics mirror the classic
// Temporal retry model so existing hanzoai/base and hanzoai/commerce
// call sites work unchanged after the import swap.
//
// Wire form (schema/tasks.zap:RetryPolicy) uses int64 milliseconds;
// Go callers work with time.Duration. Conversion happens in serde.go.
type RetryPolicy struct {
// InitialInterval is the delay before the first retry.
// Zero means "use DefaultRetryPolicy().InitialInterval".
InitialInterval time.Duration
// BackoffCoefficient multiplies the interval on each retry.
// Must be >= 1.0. Zero means "use default (2.0)".
BackoffCoefficient float64
// MaximumInterval caps the exponential backoff. Zero means
// "100 * InitialInterval" (matches upstream default).
MaximumInterval time.Duration
// MaximumAttempts bounds the total retry count including the
// first execution. 0 = unlimited, 1 = no retries.
MaximumAttempts int32
// NonRetryableErrorTypes lists error Code values that skip
// retry regardless of MaximumAttempts. See errors.go.
NonRetryableErrorTypes []string
}
// DefaultRetryPolicy returns the retry policy used when a
// workflow/activity has not configured one explicitly.
func DefaultRetryPolicy() *RetryPolicy {
return &RetryPolicy{
InitialInterval: time.Second,
BackoffCoefficient: 2.0,
MaximumInterval: 100 * time.Second,
MaximumAttempts: 0, // unlimited
}
}
// Normalize returns a copy of p with zero fields filled in from
// DefaultRetryPolicy. The input is not mutated. Callers who want
// to know the effective policy (for dispatch, for logging) use
// this. Callers who want to honour user-supplied zeros pass the
// policy through untouched.
func (p *RetryPolicy) Normalize() *RetryPolicy {
d := DefaultRetryPolicy()
if p == nil {
return d
}
out := *p
if out.InitialInterval <= 0 {
out.InitialInterval = d.InitialInterval
}
if out.BackoffCoefficient < 1 {
out.BackoffCoefficient = d.BackoffCoefficient
}
if out.MaximumInterval <= 0 {
out.MaximumInterval = 100 * out.InitialInterval
}
if out.MaximumAttempts < 0 {
out.MaximumAttempts = 0
}
return &out
}
// ShouldRetry reports whether an error produced at attempt n (1-based)
// under policy p should be retried. It is a pure function: it does
// not sleep, log, or call the network. Callers schedule the retry.
func (p *RetryPolicy) ShouldRetry(err error, attempt int32) bool {
if err == nil {
return false
}
eff := p.Normalize()
if eff.MaximumAttempts > 0 && attempt >= eff.MaximumAttempts {
return false
}
if terr, ok := AsError(err); ok {
if terr.NonRetryable {
return false
}
for _, t := range eff.NonRetryableErrorTypes {
if t == terr.Code {
return false
}
}
}
return true
}
// NextInterval returns the delay before retry number `attempt`
// (1-based — attempt=1 is the delay before the first retry, which
// happens after the initial execution failed).
func (p *RetryPolicy) NextInterval(attempt int32) time.Duration {
eff := p.Normalize()
if attempt < 1 {
attempt = 1
}
// interval = InitialInterval * BackoffCoefficient^(attempt-1),
// clamped at MaximumInterval. Computed in float64 to avoid
// overflow for small coefficients and moderate attempts.
interval := float64(eff.InitialInterval)
for i := int32(1); i < attempt; i++ {
interval *= eff.BackoffCoefficient
if interval >= float64(eff.MaximumInterval) {
return eff.MaximumInterval
}
}
return time.Duration(interval)
}