Skip to content

net/http: ReadHeaderTimeout remains active after unencrypted HTTP/2 handoff #80876

Description

@moskyb

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    CriticalA critical problem that affects the availability or correctness of production systems built using GoFixPendingIssues that have a fix which has not yet been reviewed or submitted.release-blocker

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions