Skip to content

Commit ebe7f8b

Browse files
gtardiftdabasinskas
authored andcommitted
Fallback to no proxy if desktop proxy is suddenly unavailable in the middle of a session
Signed-off-by: Guillaume Tardif <guillaume.tardif@gmail.com>
1 parent decb050 commit ebe7f8b

3 files changed

Lines changed: 212 additions & 13 deletions

File tree

pkg/httpclient/client.go

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -102,10 +102,13 @@ func newTransport(ctx context.Context) http.RoundTripper {
102102
// Get the base transport with Desktop proxy support from remote package
103103
rt := remote.NewTransport(ctx)
104104

105-
// If it's an http.Transport, disable compression for SSE streaming compatibility
106-
if transport, ok := rt.(*http.Transport); ok {
107-
transport.DisableCompression = true
108-
return transport
105+
// Disable compression for SSE streaming compatibility
106+
// Handle both direct *http.Transport and the fallback transport wrapper
107+
switch t := rt.(type) {
108+
case *http.Transport:
109+
t.DisableCompression = true
110+
case interface{ DisableCompression() }:
111+
t.DisableCompression()
109112
}
110113

111114
return rt

pkg/remote/transport.go

Lines changed: 111 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@ package remote
22

33
import (
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 (
1619
var 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.
1924
func 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+
}

pkg/remote/transport_test.go

Lines changed: 94 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,14 @@ func TestNewTransport_UsesDesktopProxyWhenAvailable(t *testing.T) {
2020
transport := NewTransport(ctx)
2121
require.NotNil(t, transport)
2222

23-
// Verify that it's an http.Transport
24-
httpTransport, ok := transport.(*http.Transport)
25-
require.True(t, ok, "transport should be *http.Transport")
26-
27-
// If Docker Desktop is running, verify proxy is configured
23+
// If Docker Desktop is running, verify fallback transport is used
2824
if desktop.IsDockerDesktopRunning(ctx) {
29-
assert.NotNil(t, httpTransport.Proxy, "proxy should be configured when Docker Desktop is running")
30-
assert.NotNil(t, httpTransport.DialContext, "custom DialContext should be set when Docker Desktop is running")
25+
_, ok := transport.(*fallbackTransport)
26+
assert.True(t, ok, "transport should be *fallbackTransport when Docker Desktop is running")
27+
} else {
28+
// Otherwise, it should be a plain *http.Transport
29+
_, ok := transport.(*http.Transport)
30+
assert.True(t, ok, "transport should be *http.Transport when Docker Desktop is not running")
3131
}
3232
}
3333

@@ -56,3 +56,90 @@ func TestNewTransport_WorksWithoutDesktopProxy(t *testing.T) {
5656

5757
assert.Equal(t, http.StatusOK, resp.StatusCode)
5858
}
59+
60+
func TestIsProxySocketError(t *testing.T) {
61+
t.Parallel()
62+
63+
tests := []struct {
64+
name string
65+
errStr string
66+
expected bool
67+
}{
68+
{
69+
name: "no such file or directory",
70+
errStr: "proxyconnect tcp: dial unix /path/to/httpproxy.sock: connect: no such file or directory",
71+
expected: true,
72+
},
73+
{
74+
name: "connection refused",
75+
errStr: "proxyconnect tcp: dial unix /path/to/httpproxy.sock: connect: connection refused",
76+
expected: true,
77+
},
78+
{
79+
name: "proxyconnect tcp error",
80+
errStr: "Post https://api.anthropic.com/v1/messages: proxyconnect tcp: some error",
81+
expected: true,
82+
},
83+
{
84+
name: "dial unix error",
85+
errStr: "dial unix /var/run/docker.sock: operation timed out",
86+
expected: true,
87+
},
88+
{
89+
name: "regular network error",
90+
errStr: "dial tcp 192.168.1.1:443: i/o timeout",
91+
expected: false,
92+
},
93+
{
94+
name: "HTTP error",
95+
errStr: "HTTP 500: internal server error",
96+
expected: false,
97+
},
98+
{
99+
name: "nil error",
100+
errStr: "",
101+
expected: false,
102+
},
103+
}
104+
105+
for _, tc := range tests {
106+
t.Run(tc.name, func(t *testing.T) {
107+
t.Parallel()
108+
var err error
109+
if tc.errStr != "" {
110+
err = &testError{msg: tc.errStr}
111+
}
112+
result := isProxySocketError(err)
113+
assert.Equal(t, tc.expected, result)
114+
})
115+
}
116+
}
117+
118+
func TestFallbackTransport_DisableCompression(t *testing.T) {
119+
t.Parallel()
120+
121+
proxy := &http.Transport{}
122+
direct := &http.Transport{}
123+
124+
ft := newFallbackTransport(proxy, direct)
125+
126+
// Verify compression is not disabled initially
127+
assert.False(t, proxy.DisableCompression)
128+
assert.False(t, direct.DisableCompression)
129+
130+
// Disable compression
131+
ft.DisableCompression()
132+
133+
// Verify compression is now disabled on both transports
134+
assert.True(t, proxy.DisableCompression)
135+
assert.True(t, direct.DisableCompression)
136+
}
137+
138+
// testError is a simple error type for testing
139+
type testError struct {
140+
msg string
141+
}
142+
143+
func (e *testError) Error() string {
144+
return e.msg
145+
}

0 commit comments

Comments
 (0)