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/...
124 lines
3.9 KiB
Go
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)
|
|
}
|