detached 3630968

fix(proxy): use longer timeout for registry reverse proxy

The registry proxy used a shared transport with 30s ResponseHeaderTimeout.
Admin endpoints like /admin/deploy perform blocking operations (image pull,
container start, readiness checks) that routinely exceed 30s. The proxy
timeout canceled the deploy context, causing readiness failures and orphaned
containers that triggered a retry loop.

Give the registry proxy its own transport with a 3-minute timeout to
accommodate the full deploy lifecycle (pull + start + health check).
commit
3630968318a14b7bed59988a6e0a18e1bcb8dace
author
brice · · 2026-02-08 16:17 UTC
parent
cc76aca
signature
unsigned
1 file · +27 -8
M internal/usecase/proxy/service.go +27 -8
@@ -24,8 +24,8 @@ import (
2424 "github.com/bnema/gordon/internal/domain"
2525 )
2626
27-// proxyTransport is a shared HTTP transport with proper timeouts.
28-// This prevents resource exhaustion from slow backends or network issues.
27+// proxyTransport is a shared HTTP transport for proxying to application containers.
28+// ResponseHeaderTimeout is kept short to detect unresponsive backends quickly.
2929 var proxyTransport = &http.Transport{
3030 DialContext: (&net.Dialer{
3131 Timeout: 10 * time.Second,
@@ -38,6 +38,21 @@ var proxyTransport = &http.Transport{
3838 IdleConnTimeout: 90 * time.Second,
3939 }
4040
41+// registryTransport is used for the registry reverse proxy (localhost loopback).
42+// It needs a longer ResponseHeaderTimeout because admin endpoints like /admin/deploy
43+// perform blocking operations (image pull, container start, readiness checks) that
44+// can take well over 30 seconds.
45+var registryTransport = &http.Transport{
46+ DialContext: (&net.Dialer{
47+ Timeout: 10 * time.Second,
48+ KeepAlive: 30 * time.Second,
49+ }).DialContext,
50+ ResponseHeaderTimeout: 3 * time.Minute,
51+ MaxIdleConns: 20,
52+ MaxIdleConnsPerHost: 10,
53+ IdleConnTimeout: 90 * time.Second,
54+}
55+
4156 // Config holds configuration needed by the proxy service.
4257 type Config struct {
4358 RegistryDomain string
@@ -558,8 +573,14 @@ func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) {
558573 return
559574 }
560575
561- proxy := newReverseProxy(targetURL, "",
562- func(w http.ResponseWriter, _ *http.Request, err error) {
576+ proxy := &httputil.ReverseProxy{
577+ Rewrite: func(pr *httputil.ProxyRequest) {
578+ pr.SetURL(targetURL)
579+ pr.SetXForwarded()
580+ pr.Out.Host = targetURL.Host
581+ },
582+ Transport: registryTransport,
583+ ErrorHandler: func(w http.ResponseWriter, _ *http.Request, err error) {
563584 var maxBytesErr *http.MaxBytesError
564585 if errors.As(err, &maxBytesErr) {
565586 log.Warn().Err(err).Int("registry_port", cfg.RegistryPort).Msg("registry proxy error: request body too large")
@@ -569,13 +590,11 @@ func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) {
569590 log.Error().Err(err).Int("registry_port", cfg.RegistryPort).Msg("registry proxy error")
570591 proxyError(w, "Registry Unavailable", http.StatusServiceUnavailable)
571592 },
572- func(resp *http.Response) error {
593+ ModifyResponse: func(resp *http.Response) error {
573594 resp.Header.Set("X-Proxied-By", "Gordon")
574595 resp.Header.Set("X-Registry-Backend", "gordon-registry")
575596
576597 // Remove security headers from registry response to prevent duplicates.
577- // The proxy middleware already adds these headers, so the registry's
578- // copies would create duplicates in the final response.
579598 resp.Header.Del("X-Content-Type-Options")
580599 resp.Header.Del("X-Frame-Options")
581600 resp.Header.Del("X-XSS-Protection")
@@ -586,7 +605,7 @@ func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) {
586605
587606 return nil
588607 },
589- )
608+ }
590609
591610 log.Debug().Str("target", targetURL.String()).Msg("proxying request to registry")
592611
@@ -24,8 +24,8 @@ import (
24 "github.com/bnema/gordon/internal/domain"24 "github.com/bnema/gordon/internal/domain"
25 )25 )
26 26
27-// proxyTransport is a shared HTTP transport with proper timeouts.27+// proxyTransport is a shared HTTP transport for proxying to application containers.
28-// This prevents resource exhaustion from slow backends or network issues.28+// ResponseHeaderTimeout is kept short to detect unresponsive backends quickly.
29 var proxyTransport = &http.Transport{29 var proxyTransport = &http.Transport{
30 DialContext: (&net.Dialer{30 DialContext: (&net.Dialer{
31 Timeout: 10 * time.Second,31 Timeout: 10 * time.Second,
@@ -38,6 +38,21 @@ var proxyTransport = &http.Transport{
38 IdleConnTimeout: 90 * time.Second,38 IdleConnTimeout: 90 * time.Second,
39 }39 }
40 40
41+// registryTransport is used for the registry reverse proxy (localhost loopback).
42+// It needs a longer ResponseHeaderTimeout because admin endpoints like /admin/deploy
43+// perform blocking operations (image pull, container start, readiness checks) that
44+// can take well over 30 seconds.
45+var registryTransport = &http.Transport{
46+ DialContext: (&net.Dialer{
47+ Timeout: 10 * time.Second,
48+ KeepAlive: 30 * time.Second,
49+ }).DialContext,
50+ ResponseHeaderTimeout: 3 * time.Minute,
51+ MaxIdleConns: 20,
52+ MaxIdleConnsPerHost: 10,
53+ IdleConnTimeout: 90 * time.Second,
54+}
55+
41 // Config holds configuration needed by the proxy service.56 // Config holds configuration needed by the proxy service.
42 type Config struct {57 type Config struct {
43 RegistryDomain string58 RegistryDomain string
@@ -558,8 +573,14 @@ func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) {
558 return573 return
559 }574 }
560 575
561- proxy := newReverseProxy(targetURL, "",576+ proxy := &httputil.ReverseProxy{
562- func(w http.ResponseWriter, _ *http.Request, err error) {577+ Rewrite: func(pr *httputil.ProxyRequest) {
578+ pr.SetURL(targetURL)
579+ pr.SetXForwarded()
580+ pr.Out.Host = targetURL.Host
581+ },
582+ Transport: registryTransport,
583+ ErrorHandler: func(w http.ResponseWriter, _ *http.Request, err error) {
563 var maxBytesErr *http.MaxBytesError584 var maxBytesErr *http.MaxBytesError
564 if errors.As(err, &maxBytesErr) {585 if errors.As(err, &maxBytesErr) {
565 log.Warn().Err(err).Int("registry_port", cfg.RegistryPort).Msg("registry proxy error: request body too large")586 log.Warn().Err(err).Int("registry_port", cfg.RegistryPort).Msg("registry proxy error: request body too large")
@@ -569,13 +590,11 @@ func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) {
569 log.Error().Err(err).Int("registry_port", cfg.RegistryPort).Msg("registry proxy error")590 log.Error().Err(err).Int("registry_port", cfg.RegistryPort).Msg("registry proxy error")
570 proxyError(w, "Registry Unavailable", http.StatusServiceUnavailable)591 proxyError(w, "Registry Unavailable", http.StatusServiceUnavailable)
571 },592 },
572- func(resp *http.Response) error {593+ ModifyResponse: func(resp *http.Response) error {
573 resp.Header.Set("X-Proxied-By", "Gordon")594 resp.Header.Set("X-Proxied-By", "Gordon")
574 resp.Header.Set("X-Registry-Backend", "gordon-registry")595 resp.Header.Set("X-Registry-Backend", "gordon-registry")
575 596
576 // Remove security headers from registry response to prevent duplicates.597 // Remove security headers from registry response to prevent duplicates.
577- // The proxy middleware already adds these headers, so the registry's
578- // copies would create duplicates in the final response.
579 resp.Header.Del("X-Content-Type-Options")598 resp.Header.Del("X-Content-Type-Options")
580 resp.Header.Del("X-Frame-Options")599 resp.Header.Del("X-Frame-Options")
581 resp.Header.Del("X-XSS-Protection")600 resp.Header.Del("X-XSS-Protection")
@@ -586,7 +605,7 @@ func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) {
586 605
587 return nil606 return nil
588 },607 },
589- )608+ }
590 609
591 log.Debug().Str("target", targetURL.String()).Msg("proxying request to registry")610 log.Debug().Str("target", targetURL.String()).Msg("proxying request to registry")
592 611

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 106

↑↓ moveenter openesc close