Retry upstream connection-refused with exponential backoff
A refused dial (service down/restarting) is retried with a wait that doubles from BACKOFF_INITIAL (1s) up to BACKOFF_MAX (60s) until the upstream answers or the client disconnects. Handles the Windows WSA errno (10061) as well as POSIX ECONNREFUSED. compose.yaml.example now uses host.docker.internal like the working local deployment.
This commit is contained in:
+93
-13
@@ -13,6 +13,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"gpu-turnstile/internal/comfy"
|
||||
@@ -41,6 +42,13 @@ type Config struct {
|
||||
UnloadTimeout time.Duration
|
||||
JobTimeout time.Duration
|
||||
|
||||
// BackoffInitial and BackoffMax control the exponential retry backoff
|
||||
// when an upstream refuses a connection: the wait doubles from
|
||||
// BackoffInitial up to BackoffMax between attempts. Zero selects the
|
||||
// defaults (1s / 60s).
|
||||
BackoffInitial time.Duration
|
||||
BackoffMax time.Duration
|
||||
|
||||
// UnloadPollInterval and HistoryPollInterval override the clients'
|
||||
// /api/ps and /history poll intervals when > 0.
|
||||
UnloadPollInterval time.Duration
|
||||
@@ -57,11 +65,13 @@ type Config struct {
|
||||
|
||||
// Server serves both gpu-turnstile listeners.
|
||||
type Server struct {
|
||||
cfg Config
|
||||
log *slog.Logger
|
||||
freeTimeout time.Duration
|
||||
warmTimeout time.Duration
|
||||
captureLimit int64
|
||||
cfg Config
|
||||
log *slog.Logger
|
||||
freeTimeout time.Duration
|
||||
warmTimeout time.Duration
|
||||
captureLimit int64
|
||||
backoffInitial time.Duration
|
||||
backoffMax time.Duration
|
||||
|
||||
ollamaProxy *httputil.ReverseProxy
|
||||
comfyProxy *httputil.ReverseProxy
|
||||
@@ -99,19 +109,89 @@ func New(cfg Config) (*Server, error) {
|
||||
if captureLimit <= 0 {
|
||||
captureLimit = defaultCaptureLimit
|
||||
}
|
||||
backoffInitial := cfg.BackoffInitial
|
||||
if backoffInitial <= 0 {
|
||||
backoffInitial = time.Second
|
||||
}
|
||||
backoffMax := cfg.BackoffMax
|
||||
if backoffMax <= 0 {
|
||||
backoffMax = time.Minute
|
||||
}
|
||||
retry := &retryTransport{
|
||||
base: http.DefaultTransport,
|
||||
initial: backoffInitial,
|
||||
max: backoffMax,
|
||||
log: log,
|
||||
}
|
||||
return &Server{
|
||||
cfg: cfg,
|
||||
log: log,
|
||||
freeTimeout: freeTimeout,
|
||||
warmTimeout: warmTimeout,
|
||||
captureLimit: captureLimit,
|
||||
ollamaProxy: newReverseProxy(ollamaURL, log.With("upstream", "ollama")),
|
||||
comfyProxy: newReverseProxy(comfyURL, log.With("upstream", "comfy")),
|
||||
cfg: cfg,
|
||||
log: log,
|
||||
freeTimeout: freeTimeout,
|
||||
warmTimeout: warmTimeout,
|
||||
captureLimit: captureLimit,
|
||||
backoffInitial: backoffInitial,
|
||||
backoffMax: backoffMax,
|
||||
ollamaProxy: newReverseProxy(ollamaURL, retry, log.With("upstream", "ollama")),
|
||||
comfyProxy: newReverseProxy(comfyURL, retry, log.With("upstream", "comfy")),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newReverseProxy(target *url.URL, log *slog.Logger) *httputil.ReverseProxy {
|
||||
// retryTransport retries requests that fail with "connection refused" —
|
||||
// typically the upstream service simply not being up yet — using an
|
||||
// exponential backoff: initial, doubling each attempt, capped at max. The
|
||||
// retry loop runs until the request succeeds, fails with a different
|
||||
// error, or the client's context is cancelled. Retrying a refused
|
||||
// connection is always safe: no request bytes were ever sent.
|
||||
type retryTransport struct {
|
||||
base http.RoundTripper
|
||||
initial time.Duration
|
||||
max time.Duration
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// wsaECONNREFUSED is the Windows error code for a refused connection.
|
||||
// syscall.ECONNREFUSED is the POSIX value and never matches on Windows,
|
||||
// where dial errors carry the WSA code.
|
||||
const wsaECONNREFUSED syscall.Errno = 10061
|
||||
|
||||
func isConnRefused(err error) bool {
|
||||
if errors.Is(err, syscall.ECONNREFUSED) {
|
||||
return true
|
||||
}
|
||||
var errno syscall.Errno
|
||||
return errors.As(err, &errno) && errno == wsaECONNREFUSED
|
||||
}
|
||||
|
||||
func (t *retryTransport) logWarn(msg string, args ...any) {
|
||||
if t.log != nil {
|
||||
t.log.Warn(msg, args...)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *retryTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
wait := t.initial
|
||||
for {
|
||||
resp, err := t.base.RoundTrip(req)
|
||||
if err == nil || !isConnRefused(err) {
|
||||
return resp, err
|
||||
}
|
||||
t.logWarn("upstream connection refused; backing off",
|
||||
"path", req.URL.Path, "retry_in", wait)
|
||||
select {
|
||||
case <-req.Context().Done():
|
||||
return nil, err
|
||||
case <-time.After(wait):
|
||||
}
|
||||
wait *= 2
|
||||
if wait > t.max {
|
||||
wait = t.max
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newReverseProxy(target *url.URL, transport http.RoundTripper, log *slog.Logger) *httputil.ReverseProxy {
|
||||
return &httputil.ReverseProxy{
|
||||
Transport: transport,
|
||||
Rewrite: func(pr *httputil.ProxyRequest) {
|
||||
pr.SetURL(target)
|
||||
pr.SetXForwarded()
|
||||
|
||||
Reference in New Issue
Block a user