@@ -2,9 +2,12 @@ package remote
22
33import (
44 "context"
5+ "log/slog"
56 "net"
67 "net/http"
78 "net/url"
9+ "strings"
10+ "sync/atomic"
811 "time"
912
1013 "github.com/kofalt/go-memoize"
@@ -16,6 +19,8 @@ import (
1619var memoizer = memoize .NewMemoizer (1 * time .Minute , 1 * time .Minute )
1720
1821// NewTransport returns an HTTP transport that uses Docker Desktop proxy if available.
22+ // If the proxy becomes unavailable during the session, it automatically falls back
23+ // to direct connections.
1924func NewTransport (ctx context.Context ) http.RoundTripper {
2025 t , ok := http .DefaultTransport .(* http.Transport )
2126 if ! ok {
@@ -30,14 +35,118 @@ func NewTransport(ctx context.Context) http.RoundTripper {
3035 return transport
3136 }
3237 if running , ok := desktopRunning .(bool ); ok && running {
33- transport .Proxy = http .ProxyURL (& url.URL {
38+ // Create a proxy transport
39+ proxyTransport := t .Clone ()
40+ proxyTransport .Proxy = http .ProxyURL (& url.URL {
3441 Scheme : "http" ,
3542 })
3643 // Override the dialer to connect to the Unix socket for the proxy
37- transport .DialContext = func (ctx context.Context , network , addr string ) (net.Conn , error ) {
44+ proxyTransport .DialContext = func (ctx context.Context , network , addr string ) (net.Conn , error ) {
3845 return socket .DialUnix (ctx , desktop .Paths ().ProxySocket )
3946 }
47+
48+ // Return a fallback transport that tries the proxy first, then falls back to direct
49+ return newFallbackTransport (proxyTransport , transport )
4050 }
4151
4252 return transport
4353}
54+
55+ // fallbackTransport wraps a proxy transport and falls back to a direct transport
56+ // when the proxy socket becomes unavailable (e.g., Docker Desktop proxy dies).
57+ type fallbackTransport struct {
58+ proxy * http.Transport
59+ direct * http.Transport
60+
61+ // proxyDisabled is set to true when the proxy socket becomes unavailable.
62+ // Once set, all subsequent requests go directly without trying the proxy.
63+ proxyDisabled atomic.Bool
64+ }
65+
66+ // newFallbackTransport creates a transport that tries the proxy first, then falls back to direct.
67+ func newFallbackTransport (proxy , direct * http.Transport ) * fallbackTransport {
68+ return & fallbackTransport {
69+ proxy : proxy ,
70+ direct : direct ,
71+ }
72+ }
73+
74+ // DisableCompression disables automatic gzip compression on both transports.
75+ // This is needed for SSE streaming compatibility.
76+ func (f * fallbackTransport ) DisableCompression () {
77+ f .proxy .DisableCompression = true
78+ f .direct .DisableCompression = true
79+ }
80+
81+ // RoundTrip implements http.RoundTripper.
82+ func (f * fallbackTransport ) RoundTrip (req * http.Request ) (* http.Response , error ) {
83+ // If proxy is already known to be disabled, go direct
84+ if f .proxyDisabled .Load () {
85+ return f .direct .RoundTrip (req )
86+ }
87+
88+ // Try the proxy first
89+ resp , err := f .proxy .RoundTrip (req )
90+ if err == nil {
91+ return resp , nil
92+ }
93+
94+ // Check if this is a proxy socket error (socket gone, connection refused, etc.)
95+ if isProxySocketError (err ) {
96+ slog .Warn ("Docker Desktop proxy unavailable, falling back to direct connection" ,
97+ "error" , err .Error (),
98+ "url" , req .URL .String ())
99+
100+ // Disable proxy for future requests
101+ f .proxyDisabled .Store (true )
102+
103+ // Clone the request for retry (the body may have been partially read)
104+ // For requests without a body or with GetBody set, we can retry
105+ if req .Body == nil || req .GetBody != nil {
106+ retryReq := req .Clone (req .Context ())
107+ if req .GetBody != nil {
108+ var bodyErr error
109+ retryReq .Body , bodyErr = req .GetBody ()
110+ if bodyErr != nil {
111+ return nil , err // Return original error if we can't get the body
112+ }
113+ }
114+ return f .direct .RoundTrip (retryReq )
115+ }
116+
117+ // Can't retry requests with consumed bodies
118+ return nil , err
119+ }
120+
121+ return nil , err
122+ }
123+
124+ // isProxySocketError checks if the error indicates the proxy socket is unavailable.
125+ // This includes:
126+ // - "no such file or directory" - socket file was deleted
127+ // - "connection refused" - socket exists but nothing is listening
128+ // - "dial unix" errors - general Unix socket connection failures
129+ func isProxySocketError (err error ) bool {
130+ if err == nil {
131+ return false
132+ }
133+
134+ errStr := strings .ToLower (err .Error ())
135+
136+ // Check for common proxy socket failure patterns
137+ proxyErrorPatterns := []string {
138+ "no such file or directory" , // Socket file deleted
139+ "connect: connection refused" , // Socket exists but no listener
140+ "proxyconnect tcp" , // Proxy connection failure
141+ "dial unix" , // Unix socket dial failure
142+ "unix socket" , // Generic Unix socket error
143+ }
144+
145+ for _ , pattern := range proxyErrorPatterns {
146+ if strings .Contains (errStr , pattern ) {
147+ return true
148+ }
149+ }
150+
151+ return false
152+ }
0 commit comments