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 ( | |||
| 24 | 24 | "github.com/bnema/gordon/internal/domain" | |
| 25 | 25 | ) | |
| 26 | 26 | ||
| 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. | |
| 29 | 29 | var proxyTransport = &http.Transport{ | |
| 30 | 30 | DialContext: (&net.Dialer{ | |
| 31 | 31 | Timeout: 10 * time.Second, | |
| @@ -38,6 +38,21 @@ var proxyTransport = &http.Transport{ | |||
| 38 | 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 | 56 | // Config holds configuration needed by the proxy service. | |
| 42 | 57 | type Config struct { | |
| 43 | 58 | RegistryDomain string | |
| @@ -558,8 +573,14 @@ func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) { | |||
| 558 | 573 | return | |
| 559 | 574 | } | |
| 560 | 575 | ||
| 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) { | |
| 563 | 584 | var maxBytesErr *http.MaxBytesError | |
| 564 | 585 | if errors.As(err, &maxBytesErr) { | |
| 565 | 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 | 590 | log.Error().Err(err).Int("registry_port", cfg.RegistryPort).Msg("registry proxy error") | |
| 570 | 591 | proxyError(w, "Registry Unavailable", http.StatusServiceUnavailable) | |
| 571 | 592 | }, | |
| 572 | - | func(resp *http.Response) error { | |
| 593 | + | ModifyResponse: func(resp *http.Response) error { | |
| 573 | 594 | resp.Header.Set("X-Proxied-By", "Gordon") | |
| 574 | 595 | resp.Header.Set("X-Registry-Backend", "gordon-registry") | |
| 575 | 596 | ||
| 576 | 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 | 598 | resp.Header.Del("X-Content-Type-Options") | |
| 580 | 599 | resp.Header.Del("X-Frame-Options") | |
| 581 | 600 | resp.Header.Del("X-XSS-Protection") | |
| @@ -586,7 +605,7 @@ func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) { | |||
| 586 | 605 | ||
| 587 | 606 | return nil | |
| 588 | 607 | }, | |
| 589 | - | ) | |
| 608 | + | } | |
| 590 | 609 | ||
| 591 | 610 | log.Debug().Str("target", targetURL.String()).Msg("proxying request to registry") | |
| 592 | 611 | ||
| @@ -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 string | 58 | RegistryDomain string | ||
| @@ -558,8 +573,14 @@ func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) { | |||||
| 558 | return | 573 | 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.MaxBytesError | 584 | 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 nil | 606 | 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 | ||||