Files
member-console/internal/workflows/client.go
T
cgalo5758 7bff4ac603 Validate startup config and retry Temporal
Fail fast with aggregated config errors after resolving secret files,
before services initialize. Add Valkey session config, remove the unused
session-secret, and fix the production CSP env key.

Retry initial Temporal dials with bounded backoff and gate the test
Temporal service on healthy DB and Keycloak dependencies.
2026-07-01 19:04:26 -05:00

170 lines
4.9 KiB
Go

package workflows
import (
"context"
"fmt"
"log/slog"
"math/rand/v2"
"time"
"go.temporal.io/sdk/client"
)
// ClientConfig holds configuration for the Temporal client.
type ClientConfig struct {
HostPort string
Namespace string
Logger *slog.Logger
OAuthTokenProvider *OAuthTokenProviderConfig
// ConnectTimeout bounds how long NewClient retries a failed initial Dial
// before giving up. Zero uses defaultConnectTimeout.
ConnectTimeout time.Duration
}
const (
// defaultConnectTimeout bounds the initial-Dial retry budget. It comfortably
// exceeds Temporal's ~1-minute JWKS refresh window, so a cold-start JWT
// rejection (server reachable, signing keys not yet loaded) self-heals
// without the operator having to re-run `start`.
defaultConnectTimeout = 90 * time.Second
connectInitialBackoff = 500 * time.Millisecond
connectMaxBackoff = 5 * time.Second
)
// DefaultClientConfig returns a ClientConfig with sensible defaults.
func DefaultClientConfig() ClientConfig {
return ClientConfig{
HostPort: "localhost:7233",
Namespace: "default",
}
}
// NewClient creates a new Temporal client.
func NewClient(ctx context.Context, cfg ClientConfig) (client.Client, error) {
if cfg.HostPort == "" {
cfg.HostPort = "localhost:7233"
}
if cfg.Namespace == "" {
cfg.Namespace = "default"
}
opts := client.Options{
HostPort: cfg.HostPort,
Namespace: cfg.Namespace,
}
if cfg.OAuthTokenProvider != nil {
headersProvider, err := NewOAuthTokenProvider(*cfg.OAuthTokenProvider)
if err != nil {
return nil, fmt.Errorf("failed to configure Temporal auth: %w", err)
}
opts.HeadersProvider = headersProvider
}
if cfg.Logger != nil {
opts.Logger = newSlogAdapter(cfg.Logger)
}
timeout := cfg.ConnectTimeout
if timeout <= 0 {
timeout = defaultConnectTimeout
}
c, err := dialWithRetry(ctx, func() (client.Client, error) { return client.Dial(opts) },
timeout, connectInitialBackoff, connectMaxBackoff, cfg.Logger)
if err != nil {
return nil, fmt.Errorf("failed to create Temporal client: %w", err)
}
if cfg.Logger != nil {
cfg.Logger.Info("connected to Temporal server",
slog.String("host", cfg.HostPort),
slog.String("namespace", cfg.Namespace))
}
return c, nil
}
// dialWithRetry dials Temporal, retrying transient boot-time failures with
// bounded exponential backoff + jitter. client.Dial eagerly issues an
// authenticated GetSystemInfo RPC, so a cold server — reachable but with a JWKS
// cache that has not yet loaded the identity provider's signing keys, briefly
// rejecting an otherwise-valid token — surfaces here as a Dial error. Rather
// than make that fatal (forcing the operator to re-run `start`), we retry within
// a bounded budget so the first boot after `docker compose up` succeeds on its
// own; the same loop rides out Temporal or IdP restarts in real deployments.
//
// dial is injected so the retry policy can be unit-tested without a live server.
func dialWithRetry(ctx context.Context, dial func() (client.Client, error), budget, initialBackoff, maxBackoff time.Duration, logger *slog.Logger) (client.Client, error) {
deadline := time.Now().Add(budget)
backoff := initialBackoff
for attempt := 1; ; attempt++ {
c, err := dial()
if err == nil {
if logger != nil && attempt > 1 {
logger.Info("connected to Temporal after retry", slog.Int("attempts", attempt))
}
return c, nil
}
if !time.Now().Before(deadline) {
return nil, fmt.Errorf("could not connect to Temporal after %d attempt(s) within %s: %w", attempt, budget, err)
}
sleep := jitter(backoff)
if until := time.Until(deadline); sleep > until {
sleep = until
}
if logger != nil {
logger.Warn("Temporal not ready yet, retrying",
slog.Int("attempt", attempt),
slog.Duration("retry_in", sleep),
slog.Any("error", err))
}
select {
case <-ctx.Done():
return nil, fmt.Errorf("Temporal connect canceled after %d attempt(s): %w", attempt, ctx.Err())
case <-time.After(sleep):
}
if backoff *= 2; backoff > maxBackoff {
backoff = maxBackoff
}
}
}
// jitter returns d plus a random fraction up to d/2, so simultaneous clients
// don't reconnect in lockstep.
func jitter(d time.Duration) time.Duration {
if d <= 0 {
return 0
}
half := d / 2
if half <= 0 {
return d
}
return d + rand.N(half)
}
// slogAdapter adapts slog.Logger to Temporal's log.Logger interface.
type slogAdapter struct {
logger *slog.Logger
}
func newSlogAdapter(logger *slog.Logger) *slogAdapter {
return &slogAdapter{logger: logger}
}
func (s *slogAdapter) Debug(msg string, keyvals ...interface{}) {
s.logger.Debug(msg, keyvals...)
}
func (s *slogAdapter) Info(msg string, keyvals ...interface{}) {
s.logger.Info(msg, keyvals...)
}
func (s *slogAdapter) Warn(msg string, keyvals ...interface{}) {
s.logger.Warn(msg, keyvals...)
}
func (s *slogAdapter) Error(msg string, keyvals ...interface{}) {
s.logger.Error(msg, keyvals...)
}