Go version
Reproduced with Go 1.25.13.
The issue isn't present in Go 1.25.12.
Description
Go 1.25.13 appears to leave the connection-level ReadHeaderTimeout deadline active after an unencrypted HTTP/2 (h2c) connection has been established.
As a result, healthy HTTP/2 connections are terminated when ReadHeaderTimeout expires, even when those connections are active
This appears to be a regression introduced by CL 797820 / commit 784132491b10, which fixed #80205.
The new code sets a read deadline before checking for the HTTP/2 client preface. After the connection is handed to the HTTP/2 server, that deadline is only cleared when Server.ReadTimeout > 0. When ReadTimeout has its default value of zero, the deadline remains active.
Reproduction
Save the following as repro.go:
package main
import (
"fmt"
"io"
"net"
"net/http"
"runtime"
"time"
)
func main() {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
panic(err)
}
serverProtocols := new(http.Protocols)
serverProtocols.SetHTTP1(true)
serverProtocols.SetUnencryptedHTTP2(true)
server := &http.Server{
Protocols: serverProtocols,
ReadHeaderTimeout: 1 * time.Second,
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("ok"))
w.(http.Flusher).Flush()
<-r.Context().Done()
}),
}
go server.Serve(listener)
defer server.Close()
clientProtocols := new(http.Protocols)
clientProtocols.SetUnencryptedHTTP2(true)
client := &http.Client{
Transport: &http.Transport{
Protocols: clientProtocols,
},
Timeout: 3 * time.Second,
}
start := time.Now()
resp, err := client.Get("http://" + listener.Addr().String())
if err != nil {
panic(err)
}
_, err = io.ReadAll(resp.Body)
fmt.Printf(
"go=%s elapsed=%.3fs read_error=%v\n",
runtime.Version(),
time.Since(start).Seconds(),
err,
)
}
Run it with Go 1.25.12:
$ go run repro.go
go=go1.25.12 elapsed=3.008s read_error=context deadline exceeded (Client.Timeout or context cancellation while reading body)
Run it with Go 1.25.13:
$ go run repro.go
go=go1.25.13 elapsed=1.001s read_error=unexpected EOF
The one-second failure corresponds exactly to ReadHeaderTimeout.
Expected behaviour
Once the HTTP/2 client preface and request headers have been read successfully, the connection-level ReadHeaderTimeout deadline should be cleared.
The stream should remain open until the client's three-second timeout expires.
Actual behaviour
With Go 1.25.13, the established HTTP/2 connection is closed after one second, when ReadHeaderTimeout expires.
Workarounds
Either of the following avoids the problem:
- Set
Server.ReadTimeout to a non-zero value. This causes the HTTP/2 implementation to clear the connection-level deadline after reading request headers.
- Set
Server.ReadHeaderTimeout to zero.
Neither is ideal because it changes the server's timeout semantics.
Impact
We encountered this in a production service behind a load balancer that communicates with the Go server over h2c. Long-lived server-streaming requests began disconnecting at their ReadHeaderTimeout, causing reconnection churn.
Go version
Reproduced with Go 1.25.13.
The issue isn't present in Go 1.25.12.
Description
Go 1.25.13 appears to leave the connection-level
ReadHeaderTimeoutdeadline active after an unencrypted HTTP/2 (h2c) connection has been established.As a result, healthy HTTP/2 connections are terminated when
ReadHeaderTimeoutexpires, even when those connections are activeThis appears to be a regression introduced by CL 797820 / commit
784132491b10, which fixed #80205.The new code sets a read deadline before checking for the HTTP/2 client preface. After the connection is handed to the HTTP/2 server, that deadline is only cleared when
Server.ReadTimeout > 0. WhenReadTimeouthas its default value of zero, the deadline remains active.Reproduction
Save the following as
repro.go:Run it with Go 1.25.12:
Run it with Go 1.25.13:
The one-second failure corresponds exactly to
ReadHeaderTimeout.Expected behaviour
Once the HTTP/2 client preface and request headers have been read successfully, the connection-level
ReadHeaderTimeoutdeadline should be cleared.The stream should remain open until the client's three-second timeout expires.
Actual behaviour
With Go 1.25.13, the established HTTP/2 connection is closed after one second, when
ReadHeaderTimeoutexpires.Workarounds
Either of the following avoids the problem:
Server.ReadTimeoutto a non-zero value. This causes the HTTP/2 implementation to clear the connection-level deadline after reading request headers.Server.ReadHeaderTimeoutto zero.Neither is ideal because it changes the server's timeout semantics.
Impact
We encountered this in a production service behind a load balancer that communicates with the Go server over h2c. Long-lived server-streaming requests began disconnecting at their
ReadHeaderTimeout, causing reconnection churn.