{"record":{"id":"07969d487070b991","repo":"tailscale/tailscale","slug":"proxy-is-closed","errorCode":null,"errorMessage":"proxy is closed","messagePattern":"proxy is closed","errorType":"http","errorClass":null,"httpStatus":503,"severity":"warning","filePath":"ipn/ipnlocal/serve.go","lineNumber":956,"sourceCode":"}\n\n// close ensures that any open backend connections get closed.\nfunc (rp *reverseProxy) close() {\n\trp.closed.Store(true)\n\tif h2cT := rp.h2cTransport.Get(func() *http.Transport { return nil }); h2cT != nil {\n\t\th2cT.CloseIdleConnections()\n\t}\n\tif httpTransport := rp.httpTransport.Get(func() *http.Transport {\n\t\treturn nil\n\t}); httpTransport != nil {\n\t\thttpTransport.CloseIdleConnections()\n\t}\n}\n\nfunc (rp *reverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {\n\tif closed := rp.closed.Load(); closed {\n\t\trp.logf(\"received a request for a proxy that's being closed or has been closed\")\n\t\thttp.Error(w, \"proxy is closed\", http.StatusServiceUnavailable)\n\t\treturn\n\t}\n\tp := &httputil.ReverseProxy{Rewrite: func(r *httputil.ProxyRequest) {\n\t\toldOutPath := r.Out.URL.Path\n\t\tr.SetURL(rp.url)\n\n\t\t// If mount point matches the request path exactly, the outbound\n\t\t// request URL was set to empty string in serveWebHandler which\n\t\t// would have resulted in the outbound path set to <proxy path>\n\t\t// + '/' in SetURL. In that case, if the proxy path was set, we\n\t\t// want to send the request to the <proxy path> (without the\n\t\t// '/') .\n\t\tif oldOutPath == \"\" && rp.url.Path != \"\" {\n\t\t\tr.Out.URL.Path = rp.url.Path\n\t\t\tr.Out.URL.RawPath = rp.url.RawPath\n\t\t}\n\n\t\t// For Unix sockets, use the URL's host (localhost) instead of the incoming host","sourceCodeStart":938,"sourceCodeEnd":974,"githubUrl":"https://github.com/tailscale/tailscale/blob/6e0912f97994f927632b34ae9e63b53d6516a6ac/ipn/ipnlocal/serve.go#L938-L974","documentation":"Each serve/funnel proxy path owns a reverseProxy. When a serve config update tears one down, close() sets an atomic closed flag and closes idle backend connections. A request that arrives after that gets 503 'proxy is closed' instead of being forwarded; the log notes a request for a proxy that is closing or closed.","triggerScenarios":"An in-flight HTTP request races a serve config change that removes or replaces the proxy; tailscaled is shutting down while funnel traffic still arrives; rapid 'tailscale serve' reconfiguration under load.","commonSituations":"CI/CD reapplying serve config on every deploy; k8s operator updating Ingress/Service resources; scale-down or restart of the node behind a funnel front end that keeps forwarding.","solutions":["Retry the request after the config change settles (seconds).","Verify the expected proxy still exists with 'tailscale serve status'.","Stage config changes so old and new proxies overlap instead of hard-swapping.","If 503s persist without config churn, collect logs and report a bug."],"exampleFix":null,"handlingStrategy":"retry","validationCode":"# Confirm the mount is still live before sending production traffic:\n$ tailscale serve status\n$ curl -fsS https://<machine>.<tailnet>.ts.net/<mount>/ -o /dev/null && echo ok","typeGuard":null,"tryCatchPattern":"Treat HTTP 503 with 'proxy is closed' as transient: retry with backoff (for example 100ms, 500ms, 2s) plus jitter; give up once the config-change window has passed.","preventionTips":["Drain traffic before removing serve mounts.","Prefer additive config updates: add the new path first, remove the old one later.","Watch 503 rates as a canary during reconfiguration windows."],"tags":["serve","funnel","tailscale","proxy","shutdown","race-condition"],"backgroundTag":"service-unavailable","analyzedSha":"6e0912f97994f927632b34ae9e63b53d6516a6ac","analyzedAt":"2026-08-18T08:17:25.280Z","contentChangedAt":"2026-08-18T08:17:25.280Z","schemaVersion":2},"datasetVersion":"2026-09-14T00:17:10.932Z"}