fix(v3/windows): fixes the 502 Bad Gateway errors that occur when using Vite as the frontend dev server in development mode#5265
Conversation
…nnection failures Fixes wailsapp#4556 When using Vite with many dynamic imports, high concurrency can cause the Vite dev server to temporarily reject connections. This change adds a retryTransport that retries failed connections up to 3 times with a 50ms delay between retries. Key changes: - Added retryTransport struct implementing http.RoundTripper - Added isConnectionError helper to detect transient connection errors - Configured custom dialer with timeout settings - Force IPv4 for localhost connections to avoid IPv6 issues on Windows This fixes the 502 Bad Gateway errors that users were experiencing during development with Vite projects that have many dynamic imports.
WalkthroughA reverse proxy for development asset serving is enhanced with a custom HTTP transport that retries transient connection failures. The implementation adds retry logic with configurable delay and forces IPv4 protocol selection through a custom Changes
Estimated code review effort🎯 2 (Simple) | ⏱️ ~12 minutes Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 golangci-lint (2.11.4)level=error msg="[linters_context] typechecking error: pattern ./...: directory prefix . does not contain main module or its selected dependencies" Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (2)
v3/internal/assetserver/build_dev.go (2)
46-56: String-based error detection is fragile but pragmatic for dev tooling.This approach works but could break if Go changes error message wording. A more robust alternative would use type assertions:
♻️ Optional: Type-based error detection
import ( "errors" "net" "syscall" ) func isConnectionError(err error) bool { if err == nil { return false } var netErr *net.OpError if errors.As(err, &netErr) { var syscallErr syscall.Errno if errors.As(netErr.Err, &syscallErr) { switch syscallErr { case syscall.ECONNREFUSED, syscall.ECONNRESET, syscall.EPIPE: return true } } } // Fallback to string matching for platform-specific errors like Windows connectex errStr := strings.ToLower(err.Error()) return strings.Contains(errStr, "connectex") }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@v3/internal/assetserver/build_dev.go` around lines 46 - 56, Replace fragile string-only checks in isConnectionError with type-based detection: use errors.As to check for *net.OpError and then extract a syscall.Errno to match syscall.ECONNREFUSED, syscall.ECONNRESET, and syscall.EPIPE, and keep a fallback string check only for platform-specific messages like "connectex"; update isConnectionError to import and use errors, net, and syscall accordingly while preserving the existing nil guard and fallback behavior.
74-92: Consider documenting the retry parameters and enhancing Transport configuration.The retry parameters (50 retries, 50ms delay = 2.5s max) are effective but hardcoded. A brief comment explaining the rationale would help future maintainers.
The custom
http.Transportonly configuresDialContext, leaving other settings at zero-values (vs.http.DefaultTransportwhich has tuned pooling/timeout settings). For a dev-only asset server this is acceptable, but consider copying relevant settings fromhttp.DefaultTransportif connection pooling issues arise.📝 Suggested documentation
proxy.Transport = &retryTransport{ base: &http.Transport{ + // Inherit sensible defaults for connection pooling + MaxIdleConns: 100, + IdleConnTimeout: 90 * time.Second, DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { // Force IPv4 for localhost connections to avoid IPv6 issues on Windows if parsedURL.Hostname() == "localhost" || parsedURL.Hostname() == "127.0.0.1" { return dialer.DialContext(ctx, "tcp4", addr) } return dialer.DialContext(ctx, network, addr) }, }, - maxRetries: 50, - delay: 50 * time.Millisecond, + maxRetries: 50, // High retry count to handle Vite's concurrent connection bursts + delay: 50 * time.Millisecond, // Total max retry window: ~2.5s }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@v3/internal/assetserver/build_dev.go` around lines 74 - 92, Add a short inline comment above the retryTransport instantiation explaining the choice of maxRetries=50 and delay=50ms (total ~2.5s) so maintainers know the rationale and can tune for dev use, and make those values easy to change (e.g., named constants or configuration) instead of magic numbers; also improve the custom http.Transport used as retryTransport.base by copying relevant production-tuned fields from http.DefaultTransport (or cloning http.DefaultTransport and only overriding DialContext) so connection pooling, IdleConnTimeout, TLSHandshakeTimeout, MaxIdleConnsPerHost, etc., are preserved while still forcing "tcp4" for localhost via DialContext; reference retryTransport, proxy.Transport, DialContext, parsedURL and dialer when locating where to apply these changes.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@v3/internal/assetserver/build_dev.go`:
- Around line 28-44: The RoundTrip retry loop in retryTransport.RoundTrip
consumes req.Body on the first t.base.RoundTrip(req) call which makes subsequent
retries send an empty body; buffer the request body (if non-nil) before the
first attempt (read into memory or a temporary buffer) and, before each retry,
replace req.Body with a new io.ReadCloser that replays the buffered bytes and
reset ContentLength as needed; also check req.Context().Err() before
sleeping/continuing to avoid retrying when the client has disconnected, and
ensure any response bodies from failed attempts are closed to avoid leaks (use
the existing isConnectionError check to decide retries).
---
Nitpick comments:
In `@v3/internal/assetserver/build_dev.go`:
- Around line 46-56: Replace fragile string-only checks in isConnectionError
with type-based detection: use errors.As to check for *net.OpError and then
extract a syscall.Errno to match syscall.ECONNREFUSED, syscall.ECONNRESET, and
syscall.EPIPE, and keep a fallback string check only for platform-specific
messages like "connectex"; update isConnectionError to import and use errors,
net, and syscall accordingly while preserving the existing nil guard and
fallback behavior.
- Around line 74-92: Add a short inline comment above the retryTransport
instantiation explaining the choice of maxRetries=50 and delay=50ms (total
~2.5s) so maintainers know the rationale and can tune for dev use, and make
those values easy to change (e.g., named constants or configuration) instead of
magic numbers; also improve the custom http.Transport used as
retryTransport.base by copying relevant production-tuned fields from
http.DefaultTransport (or cloning http.DefaultTransport and only overriding
DialContext) so connection pooling, IdleConnTimeout, TLSHandshakeTimeout,
MaxIdleConnsPerHost, etc., are preserved while still forcing "tcp4" for
localhost via DialContext; reference retryTransport, proxy.Transport,
DialContext, parsedURL and dialer when locating where to apply these changes.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: b000ee5d-bd81-49cd-8531-cc7818478a6b
📒 Files selected for processing (1)
v3/internal/assetserver/build_dev.go
| func (t *retryTransport) RoundTrip(req *http.Request) (*http.Response, error) { | ||
| var resp *http.Response | ||
| var err error | ||
| for i := 0; i < t.maxRetries; i++ { | ||
| resp, err = t.base.RoundTrip(req) | ||
| if err == nil { | ||
| return resp, nil | ||
| } | ||
| // Only retry on connection errors (e.g., connection refused) | ||
| if isConnectionError(err) && i < t.maxRetries-1 { | ||
| time.Sleep(t.delay) | ||
| continue | ||
| } | ||
| break | ||
| } | ||
| return resp, err | ||
| } |
There was a problem hiding this comment.
Request body is consumed on first attempt, breaking retries for POST/PUT requests.
When t.base.RoundTrip(req) is called, it consumes req.Body. Subsequent retry attempts will send an empty body, causing silent failures for non-GET requests (e.g., HMR websocket upgrades, API proxying).
For a dev asset server, GET requests dominate so this may work in practice, but POST/PUT requests through the proxy will fail on retry.
Additionally, consider checking req.Context().Err() before each retry to avoid unnecessary retries if the client has disconnected.
🛠️ Suggested fix with body preservation and context check
+import (
+ "bytes"
+ "io"
+ // ... existing imports
+)
func (t *retryTransport) RoundTrip(req *http.Request) (*http.Response, error) {
+ // Buffer the body for potential retries
+ var bodyBytes []byte
+ if req.Body != nil {
+ var err error
+ bodyBytes, err = io.ReadAll(req.Body)
+ req.Body.Close()
+ if err != nil {
+ return nil, err
+ }
+ }
+
var resp *http.Response
var err error
for i := 0; i < t.maxRetries; i++ {
+ // Check if context is cancelled
+ if req.Context().Err() != nil {
+ return nil, req.Context().Err()
+ }
+ // Reset body for each attempt
+ if bodyBytes != nil {
+ req.Body = io.NopCloser(bytes.NewReader(bodyBytes))
+ }
resp, err = t.base.RoundTrip(req)
if err == nil {
return resp, nil
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| func (t *retryTransport) RoundTrip(req *http.Request) (*http.Response, error) { | |
| var resp *http.Response | |
| var err error | |
| for i := 0; i < t.maxRetries; i++ { | |
| resp, err = t.base.RoundTrip(req) | |
| if err == nil { | |
| return resp, nil | |
| } | |
| // Only retry on connection errors (e.g., connection refused) | |
| if isConnectionError(err) && i < t.maxRetries-1 { | |
| time.Sleep(t.delay) | |
| continue | |
| } | |
| break | |
| } | |
| return resp, err | |
| } | |
| func (t *retryTransport) RoundTrip(req *http.Request) (*http.Response, error) { | |
| // Buffer the body for potential retries | |
| var bodyBytes []byte | |
| if req.Body != nil { | |
| var err error | |
| bodyBytes, err = io.ReadAll(req.Body) | |
| req.Body.Close() | |
| if err != nil { | |
| return nil, err | |
| } | |
| } | |
| var resp *http.Response | |
| var err error | |
| for i := 0; i < t.maxRetries; i++ { | |
| // Check if context is cancelled | |
| if req.Context().Err() != nil { | |
| return nil, req.Context().Err() | |
| } | |
| // Reset body for each attempt | |
| if bodyBytes != nil { | |
| req.Body = io.NopCloser(bytes.NewReader(bodyBytes)) | |
| } | |
| resp, err = t.base.RoundTrip(req) | |
| if err == nil { | |
| return resp, nil | |
| } | |
| // Only retry on connection errors (e.g., connection refused) | |
| if isConnectionError(err) && i < t.maxRetries-1 { | |
| time.Sleep(t.delay) | |
| continue | |
| } | |
| break | |
| } | |
| return resp, err | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@v3/internal/assetserver/build_dev.go` around lines 28 - 44, The RoundTrip
retry loop in retryTransport.RoundTrip consumes req.Body on the first
t.base.RoundTrip(req) call which makes subsequent retries send an empty body;
buffer the request body (if non-nil) before the first attempt (read into memory
or a temporary buffer) and, before each retry, replace req.Body with a new
io.ReadCloser that replays the buffered bytes and reset ContentLength as needed;
also check req.Context().Err() before sleeping/continuing to avoid retrying when
the client has disconnected, and ensure any response bodies from failed attempts
are closed to avoid leaks (use the existing isConnectionError check to decide
retries).
Description
This PR fixes the 502 Bad Gateway errors that occur when using Vite as the frontend dev server in development mode, particularly with projects that have many dynamic imports.
Problem: When a Vite project has many dynamic imports (e.g., lucide-solid icons), it generates hundreds of concurrent HTTP requests during page load. The Vite dev server may temporarily exhaust its connection queue, causing TCP connections to be refused. The Wails proxy's
ErrorHandlerimmediately returned 502 without any retry mechanism.Solution: Added a
retryTransportstruct that implementshttp.RoundTripperwith retry logic:Fixes #4556
Type of change
How Has This Been Tested?
Tested with a Vite + SolidJS project using lucide-solid icon library (hundreds of dynamic imports).
Execute
location.reload()in WebView console.Before fix: ~50+ 502 errors during initial page load
After fix: All requests succeed after retries, no 502 errors in browser console
Test Configuration
Project tested: post-pigeon (Vite 8.0.10 + SolidJS 1.9.12 + lucide-solid 1.8.0)
Checklist:
v3/UNRELEASED_CHANGELOG.mdwith details of this PRSummary by CodeRabbit