Skip to content

Go SDK Retries #4228

Description

@igor-kupczynski

How the Go SDK retries today, and what I propose to change. This first part documents the current state only; proposed changes to follow.

Architecture

The v1 SDK (sdks/go/) is a thin wrapper over the legacy v0 client (pkg/client/). It adds no transport logic of its own — feature clients call the legacy REST client or the gRPC sub-clients directly, so all retry behavior lives in pkg/client/.

We have these categories of clients:

  • gRPC unary calls — request/response RPCs.
  • gRPC streams and listeners — long-lived subscriptions that Recv in a loop.
  • REST / HTTP — the generated REST client.

Retry is configured in two places: the gRPC dial + interceptors in pkg/client/client.go, and the REST client, also built in client.go on the default http.Client with a request hook that adds the Authorization: Bearer <token> header and nothing else.

Current state

1. gRPC unary: single shared policy

All unary RPCs flow through one chained interceptor installed at dial time:

  • Max 5 attempts.
  • Exponential backoff with jitter (5s base, 10% jitter).
  • 30s per-retry timeout.
  • Retried codes: ResourceExhausted, DeadlineExceeded, FailedPrecondition, Internal, Unavailable.
  • Globally disable-able via NoGrpcRetry.

This covers WorkflowService, AdminService v1, Dispatcher, V1Dispatcher, and EventsService. The SDK's Admin() holds both the v0 WorkflowService and v1 AdminService stubs, and both wrap the same retry conn — so every admin RPC the SDK issues is retried. This layer is uniform.

Note: a second, retry-less gRPC dialer exists at pkg/client/v1/grpc-client.go, but it is not an SDK path. It is hatchet-api's internal REST→gRPC proxy client, which forwards inbound REST calls to the engine's gRPC admin service. We consider it out of scope for this project.

2. gRPC streams and listeners: each is different

The retries here can mean two things:

  • Automatic gRPC retry (interceptor) — a built-in gRPC retry layer beneath the stream (same 5x policy as section 1), handled by the library with no app code involved. When enabled, a transient gRPC error (either while opening the stream or after events have already been received) is handled without the caller seeing it. The interceptor drops the failed stream, opens a new one, re-sends the original subscribe request, and resumes delivery to the same Recv loop, for up to 5 attempts in total (the original plus up to 4 reopens, with backoff) before the error is finally returned. The retry opens a fresh subscription rather than resuming the broken one. Continuity across the gap is not guaranteed and events around the failure window can be dropped. When disabled (On/Stream pass grpc_retry.Disable()), there is no retry and the first error is returned immediately.
  • Reconnect loop (hand-rolled) — application-level code that opens a fresh stream and re-subscribes after a live stream drops, layered on top of (or instead of) the interceptor.

Streams

Streams open a single gRPC stream, consume it in one Recv loop, and return on the first error the interceptor doesn't absorb. There is no hand-rolled reconnect, so recovery beyond that is up to the SDK consumer.

Stream Automatic retry (5x) On mid-stream error
On / Stream (SubscribeToWorkflowEvents) Disabled (grpc_retry.Disable()) Returns on first non-EOF error
StreamByAdditionalMetadata (SubscribeToWorkflowEvents) Enabled Retriable codes retried ≤5x (stream re-opened, subscribe replayed); other errors returned

Listeners

Listeners are long-lived objects that own a reconnect loop and re-subscribe across many underlying streams over their lifetime.

Listener What it delivers Automatic retry (5x) Reconnect loop (hand-rolled) Recovery after giving up
Worker action listener Listen / ListenV2 Work assignments to the worker (the tasks it should run) Enabled Yes — up to 5 resubscribe attempts None — it stops delivering work and raises a fatal error, so the worker stops processing. Recovery means restarting the worker.
WorkflowRunsListener Run-completion notifications to callers awaiting a run Disabled Yes — stops after 10 consecutive failures The loop stops and anything currently awaiting a run result hangs. Restarts on its own the next time the app awaits a run, re-subscribing the still-pending ones.
DurableEventsListener Durable-event/signal deliveries to waiting durable tasks Disabled Yes — stops after 10 consecutive failures Same as WorkflowRunsListener, but restarts the next time a durable task waits on an event.
DurableTaskListener (bidi) Durable-task requests/responses between worker and engine Disabled Yes — unlimited, retries every 2s N/A — never gives up.

3. REST / HTTP: no retries

The REST client is the default http.Client with no retry transport. How a non-2xx is surfaced varies by method.

REST operations with zero retry: CronClient/CronsClient (Create/Delete/List/Get), ScheduleClient/SchedulesClient (Create/Delete/List/Get), RunsClient (REST parts; GetDetails and the stream path are gRPC), WorkflowsClient, WorkersClient, MetricsClient, RateLimitsClient (REST parts), CELClient, FiltersClient, LogsClient, WebhooksClient, and all cloudrest endpoints.

Discrepancies

  1. REST / HTTP performs no retries.
  2. Most streaming listeners stop their active loop after errors. The worker action listener stops on the first reconnect that fully fails (the worker returns on that first error); WorkflowRunsListener and DurableEventsListener (after 10 consecutive) exit their listen goroutine, stranding in-flight waiters. The latter two are not permanently dead, as a later AddWorkflowRun / AddSignal re-arms the loop and re-subscribes still-registered handlers. There is no automatic, transparent recovery. Only DurableTaskListener reconnects on its own.
  3. The same capability retries differently depending on which API you call. Several features are reachable over both REST (no retry) and gRPC (5x), so retry behavior depends on the entry point, not the operation:
    • Schedules / crons — the public Schedules() / Crons() methods (Create / Delete / List / Get) are REST (0 retry), but schedules/crons defined in code on a workflow (registered over gRPC) or created through the legacy gRPC scheduling call are retried (5x).
    • Run data — fetching full run details over gRPC (GetRunDetails) is retried (5x), while the public REST reads (Runs().Get / GetStatus / List) are not (0).
    • Rate limits — defining a rate limit over gRPC (PutRateLimit) is retried (5x), while listing them over REST is not (0).
  4. Stream interceptor retry is applied inconsistently. On/Stream pass grpc_retry.Disable(), while StreamByAdditionalMetadata and the worker Listen/ListenV2 streams leave the interceptor enabled — so retriable Recv failures are silently retried (≤5x, re-opening the stream) on some streams but propagate immediately on others within the same layer.
  5. Reconnect logic is duplicated and uncentralized. Four hand-rolled reconnect loops use different constants (5 vs 10 thresholds; fixed 1s/2s/5s sleeps) with no shared code, and none reuse the interceptor's exponential backoff.

Comparison with TypeScript / Python / Ruby

A quick cross-SDK pass shows the same patterns and the same or worse inconsistencies as Go:

  • Go is the only SDK that retries unary gRPC via a dial-time interceptor, so its unary coverage is uniform and its retried codes are an explicit allowlist. TS (retrier wrapper) and Python (tenacity decorator) retry per call-site, so coverage is patchy and they retry on almost any error. Ruby has no unary retry at all.
  • Go is the only SDK with a per-attempt deadline (30s). TS/Python/Ruby unary calls and most REST calls have no timeout and can hang (Ruby REST is the lone exception, 60s default).
  • REST is unretried in Go/TS/Ruby; only Python retries REST (reads only, and oddly 404s too).
  • Stream reconnect is hand-rolled with different constants in every SDK — so discrepancies chore: add root package.json with workspaces  #2 and feat: improve debugging experience of workflow runs #5 above are cross-SDK, not Go-specific.
  • gRPC keepalive is identical across all four SDKs (10s / 60s).

I suggest that after we align the Go SDK we will port the same changes to the other SDKs.

Proposed changes

Key tenets

Two different retry policies (for reads and for writes)

  • Retry policy is keyed on operation semantics: whether a call is retried depends on idempotency.
  • The SDK auto-retries idempotent reads, plus the one safe write case: a dial/connection failure where the request never reached the server.
  • We'd need idempotency keys to safely retry writes in general.
  • The policy doesn't depend on transport: the same policy applies whether the call goes over REST or gRPC.

Respect the caller's context

This is idiomatic Go and also the principle of least surprise for callers. The SDK will retry, but only as long as the caller's context allows it.

Centralized retry primitives

A single backoff/jitter primitive replaces the scattered interceptor + four separate reconnect loops, shared by the unary interceptor, the REST RoundTripper, and the stream reconnect loop.

Streams reconnect as long as they are wanted

  • Worker-owned infra streams reconnect forever.
  • Caller subscriptions reconnect while the caller's context is live.
  • We don't change the lossy reconnect in streams (i.e., after a reconnect we get only new events, there's no replay of missed events).

Behavior

  • No new SDK APIs.
    • The SDK absorbs the safe "not sent" case internally (gRPC transparent retry; REST dial-retry).
    • By the time an error reaches the caller it's either "may have executed" (DeadlineExceeded, post-send Unavailable) or "won't succeed as-is" (InvalidArgument, AlreadyExists → existing DedupeViolationErr, …).
    • Callers tell these apart with status.Code(err) (gRPC) and errors.As(err, &net.OpError{}) (REST); we just guarantee the underlying error is never flattened.
    • No new error types or signatures.
  • Retry only genuinely transient codes: Unavailable, ResourceExhausted, Internal, DeadlineExceeded.
    • Drop FailedPrecondition: wrong system state, not a transient fault.
    • Keep Internal: it usually wraps a transient transport error (HTTP/2 reset, proxy/LB hiccup), and only idempotent reads retry, so the double-execute risk never applies.
    • DeadlineExceeded only while the caller's context still has budget.
  • REST retries only 502/503/504.
    • Not 4xx.
    • Bonus: REST honors 429/Retry-After.
  • Full jitter replaces today's 10% jitter.
  • One reconnect loop for streams.
    • Replaces the four per-listener loops (Listen/ListenV2, WorkflowRunsListener, DurableEventsListener, DurableTaskListener).
    • Drops their ad-hoc constants (5 vs 10 thresholds; 1s/2s/5s sleeps).

Defaults

  • Two backoff profiles, both full jitter, factor 2x (sync calls and long-lived streams want opposite things).
    • Unary + REST: base 250ms, per-delay cap 5s, max 4 retries; hard-bounded by the caller's context deadline. ~30s per-attempt backstop only when no deadline is set; lowers today's 5s base.
    • Streams: base 1s, per-delay cap 30s, no attempt cap (reconnect while wanted).
  • Config stays internal with sane defaults.
    • Exposed controls: the context deadline (the real per-call bound) and one on/off escape hatch (today's NoGrpcRetry, generalized to "disable SDK retries").
    • No per-call attempt/backoff/code knobs now; add a functional-option config later if demand appears (non-breaking).

Out of scope (deferred)

  • Load-shedding during a brownout: retry budget, circuit breaker
    • The gRPC hot path already runs uniform 5x retries with no budget today, so this work doesn't regress it.
    • Deffered for now, but I'd like to revisit it soon after this one (gRPC's native retryThrottling is the near-free first step; a circuit breaker is a separate, more invasive feature we likely don't need).
  • Idempotency keys on RunWorkflow/Push — the future unlock that would make write retries safe and let the SDK be the single retry layer for writes too.
  • The internal REST→gRPC proxy client at pkg/client/v1/grpc-client.go (not an SDK path).

Metadata

Metadata

Labels

acceptedIssues that have been accepted by maintainers.

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions