You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
As a developer working on teacher-workspace, I want measured values for the backend proxies' transport settings, so the implement issue can be built against real numbers instead of guesses.
Background
Both proxies set only Rewrite and ErrorHandler, so ReverseProxy.Transport is nil and every proxied request runs on http.DefaultTransport. That default is built for a general-purpose client, not for a reverse proxy that sends all its traffic to a small fixed set of hosts. Two problems follow from it, and neither can be fixed without picking numbers we do not have yet.
The deployment this is being sized for
Every number below is chosen against this, so it is stated first rather than left to be discovered:
The call is internal, on the same subnet. The server opens a TCP connection straight to the backend process.
What matters here is as much what is missing as what is there. The fields it leaves at their zero value:
Field
Zero value means
Effect on us
ResponseHeaderTimeout
no limit
the wait for the reply to start never ends
MaxIdleConnsPerHost
falls back to 2
past 2 in flight, a new connection per request
MaxConnsPerHost
no limit
nothing ever waits for a free connection
ReadBufferSize, WriteBufferSize
4KB each
not yet known whether 4KB suits our bodies
Two of the values it does set are irrelevant to us. TLSHandshakeTimeout never runs, because the backend hop is plain HTTP. MaxIdleConns of 100 is a global cap we will never approach with two backends.
Where each setting applies
The call path through ReverseProxy.ServeHTTP. Steps 4 and 6 have no limit today. Steps 1 and 7 are the pool.
flowchart TB
A["ReverseProxy.ServeHTTP<br/>p.Transport is nil, so http.DefaultTransport is used"]
B["outreq := req.Clone(ctx)<br/>ctx is req.Context.<br/>A deadline set here is the only thing<br/>that can reach step 6."]
C["p.Rewrite<br/>our proxyRewriter"]
D["transport.RoundTrip(outreq)"]
S1["1. get a connection<br/>reuse a kept one, or dial<br/>MaxIdleConnsPerHost 2, MaxConnsPerHost 0"]
S2["2. dial, only when none is free<br/>Dialer.Timeout 30s"]
S3["3. write the request<br/>ExpectContinueTimeout 1s,<br/>only with an Expect header"]
S4["4. wait for the reply to start<br/>ResponseHeaderTimeout NOT SET"]
ERR["p.ErrorHandler<br/>our proxyErrorHandler<br/>502 Bad Gateway, logged"]
S5["5. rw.WriteHeader<br/>status and headers sent to the browser<br/>no 502 is possible after this"]
S6["6. p.copyResponse<br/>read the reply body, stream it out<br/>NO TRANSPORT SETTING COVERS THIS"]
PANIC["panic http.ErrAbortHandler<br/>http.Server recovers it and logs nothing"]
S7["7. res.Body.Close<br/>connection kept for the next request,<br/>IdleConnTimeout 90s, or closed"]
A --> B --> C --> D --> S1
S1 -->|"none free"| S2
S1 -->|"one free"| S3
S2 --> S3
S2 -->|"dial fails or times out"| ERR
S3 --> S4
S4 -->|"times out or errors"| ERR
S4 -->|"headers arrive"| S5
S5 --> S6
S6 -->|"read fails or stalls"| PANIC
S6 -->|"body copied"| S7
Loading
#
Stage
Setting
Today
What it does
1
Get a connection
Transport.MaxIdleConnsPerHost
2, the default
past 2 in flight, no kept connection is free
2
Dial the backend
Dialer.Timeout
30s
502 via ErrorHandler when it expires
3
Send the request
Transport.ExpectContinueTimeout
1s, only with an Expect header
the body is sent anyway when it expires
4
Wait for the reply to start
Transport.ResponseHeaderTimeout
not set
nothing. Waits until the browser gives up
5
Send status and headers
none
n/a
after this a 502 is impossible
6
Read the reply body
none
no limit
nothing. Waits until the browser gives up
7
Keep or close the connection
Transport.IdleConnTimeout
90s
how long an unused connection is kept
Everything from step 1 to step 4 fails into p.ErrorHandler, which is our proxyErrorHandler, so the caller gets a 502 and we log it. Step 6 has no such path: it panics with http.ErrAbortHandler, which http.Server recovers while suppressing the log (reverseproxy.go, server.go). So the two unbounded steps need different fixes and give different results.
The http.Server timeouts are not in this table because they bound the browser side, not the backend side. WriteTimeout (30s) is the one people expect to cover this. It does not: it only limits writes to the browser, and the handler is stuck reading from the backend.
Two facts that narrow the options
Transport.ResponseHeaderTimeout bounds the wait for reply headers only. Its doc says that time "does not include the time to read the response body". So it fixes step 4 and cannot fix step 6.
ReverseProxy.ServeHTTP builds the outbound request as outreq := req.Clone(ctx) from req.Context(). A deadline on the inbound context reaches the backend call and the body read. That is the only lever for step 6.
Prior claims
AC3 of #40 covers a backend that cannot be reached. A closed port fails at once and returns a 502 either way. It does not cover a backend that is up but not answering.
#58 reported this as done (add-1 in the 2026-08-03 comment on #40). It is not. Transport, ResponseHeaderTimeout, TimeoutHandler and context.WithTimeout appear nowhere under server/.
Acceptance criteria
A recommended value for every setting is delivered
Given the settings listed under Spike question
When the spike concludes
Then a written recommendation names a value for each one, or records a decision to leave it at the default
And each value cites the measurement or constraint it came from, not a rule of thumb
And it states which settings become TW_* environment variables and which are fixed in code
And it accounts for all 25 exported fields on http.Transport, each with a chosen value or a one-line reason it stays at its default, so nothing is left decided by accident
Out of scope
Retries, circuit breaking and rate limiting.
Spike question
Which of http.Transport's settings do we need to set on the backend proxies, and what value should each one take?
User story
As a developer working on teacher-workspace, I want measured values for the backend proxies' transport settings, so the implement issue can be built against real numbers instead of guesses.
Background
Both proxies set only
RewriteandErrorHandler, soReverseProxy.Transportis nil and every proxied request runs onhttp.DefaultTransport. That default is built for a general-purpose client, not for a reverse proxy that sends all its traffic to a small fixed set of hosts. Two problems follow from it, and neither can be fixed without picking numbers we do not have yet.The deployment this is being sized for
Every number below is chosen against this, so it is stated first rather than left to be discovered:
What DefaultTransport sets today
This is the whole of
DefaultTransport, fromtransport.goin Go 1.26.5:What matters here is as much what is missing as what is there. The fields it leaves at their zero value:
ResponseHeaderTimeoutMaxIdleConnsPerHostMaxConnsPerHostReadBufferSize,WriteBufferSizeTwo of the values it does set are irrelevant to us.
TLSHandshakeTimeoutnever runs, because the backend hop is plain HTTP.MaxIdleConnsof 100 is a global cap we will never approach with two backends.Where each setting applies
The call path through
ReverseProxy.ServeHTTP. Steps 4 and 6 have no limit today. Steps 1 and 7 are the pool.flowchart TB A["ReverseProxy.ServeHTTP<br/>p.Transport is nil, so http.DefaultTransport is used"] B["outreq := req.Clone(ctx)<br/>ctx is req.Context.<br/>A deadline set here is the only thing<br/>that can reach step 6."] C["p.Rewrite<br/>our proxyRewriter"] D["transport.RoundTrip(outreq)"] S1["1. get a connection<br/>reuse a kept one, or dial<br/>MaxIdleConnsPerHost 2, MaxConnsPerHost 0"] S2["2. dial, only when none is free<br/>Dialer.Timeout 30s"] S3["3. write the request<br/>ExpectContinueTimeout 1s,<br/>only with an Expect header"] S4["4. wait for the reply to start<br/>ResponseHeaderTimeout NOT SET"] ERR["p.ErrorHandler<br/>our proxyErrorHandler<br/>502 Bad Gateway, logged"] S5["5. rw.WriteHeader<br/>status and headers sent to the browser<br/>no 502 is possible after this"] S6["6. p.copyResponse<br/>read the reply body, stream it out<br/>NO TRANSPORT SETTING COVERS THIS"] PANIC["panic http.ErrAbortHandler<br/>http.Server recovers it and logs nothing"] S7["7. res.Body.Close<br/>connection kept for the next request,<br/>IdleConnTimeout 90s, or closed"] A --> B --> C --> D --> S1 S1 -->|"none free"| S2 S1 -->|"one free"| S3 S2 --> S3 S2 -->|"dial fails or times out"| ERR S3 --> S4 S4 -->|"times out or errors"| ERR S4 -->|"headers arrive"| S5 S5 --> S6 S6 -->|"read fails or stalls"| PANIC S6 -->|"body copied"| S7Transport.MaxIdleConnsPerHostDialer.TimeoutErrorHandlerwhen it expiresTransport.ExpectContinueTimeoutExpectheaderTransport.ResponseHeaderTimeoutTransport.IdleConnTimeoutEverything from step 1 to step 4 fails into
p.ErrorHandler, which is ourproxyErrorHandler, so the caller gets a 502 and we log it. Step 6 has no such path: it panics withhttp.ErrAbortHandler, whichhttp.Serverrecovers while suppressing the log (reverseproxy.go,server.go). So the two unbounded steps need different fixes and give different results.The
http.Servertimeouts are not in this table because they bound the browser side, not the backend side.WriteTimeout(30s) is the one people expect to cover this. It does not: it only limits writes to the browser, and the handler is stuck reading from the backend.Two facts that narrow the options
Transport.ResponseHeaderTimeoutbounds the wait for reply headers only. Its doc says that time "does not include the time to read the response body". So it fixes step 4 and cannot fix step 6.ReverseProxy.ServeHTTPbuilds the outbound request asoutreq := req.Clone(ctx)fromreq.Context(). A deadline on the inbound context reaches the backend call and the body read. That is the only lever for step 6.Prior claims
AC3 of #40 covers a backend that cannot be reached. A closed port fails at once and returns a 502 either way. It does not cover a backend that is up but not answering.
#58 reported this as done (add-1 in the 2026-08-03 comment on #40). It is not.
Transport,ResponseHeaderTimeout,TimeoutHandlerandcontext.WithTimeoutappear nowhere underserver/.Acceptance criteria
A recommended value for every setting is delivered
TW_*environment variables and which are fixed in codehttp.Transport, each with a chosen value or a one-line reason it stays at its default, so nothing is left decided by accidentOut of scope
Spike question
Which of http.Transport's settings do we need to set on the backend proxies, and what value should each one take?
Proxyfunc(*Request) (*url.URL, error)ProxyFromEnvironmentDialContextfunc(...) (net.Conn, error)Dialfunc(...) (net.Conn, error)DisableKeepAlivesboolDisableCompressionboolMaxIdleConnsintMaxIdleConnsPerHostintMaxConnsPerHostintIdleConnTimeouttime.DurationResponseHeaderTimeouttime.DurationExpectContinueTimeouttime.DurationMaxResponseHeaderBytesint64WriteBufferSizeintReadBufferSizeintForceAttemptHTTP2boolHTTP2*HTTP2ConfigProtocols*Protocols🤖 Generated with create-issue