fix(http): add transport timeouts so tea fails fast on stalled servers (#1020)

Fixes #1018

Co-authored-by: dinsmoor <204368+dinsmoor@noreply.gitea.com>
Co-committed-by: dinsmoor <204368+dinsmoor@noreply.gitea.com>
This commit is contained in:
dinsmoor
2026-06-26 19:59:05 +00:00
committed by techknowlogick
parent 6a57af24ad
commit 885381e3e4
6 changed files with 178 additions and 20 deletions
+1 -3
View File
@@ -31,9 +31,7 @@ func NewClient(login *config.Login) *Client {
} }
httpClient := &http.Client{ httpClient := &http.Client{
Transport: httputil.WrapTransport(&http.Transport{ Transport: httputil.WrapTransport(&tls.Config{InsecureSkipVerify: login.Insecure}),
TLSClientConfig: &tls.Config{InsecureSkipVerify: login.Insecure},
}),
} }
return &Client{ return &Client{
+1 -3
View File
@@ -201,9 +201,7 @@ func performBrowserOAuthFlow(ctx context.Context, opts OAuthOptions) (serverURL
// createHTTPClient creates an HTTP client with optional insecure setting // createHTTPClient creates an HTTP client with optional insecure setting
func createHTTPClient(insecure bool) *http.Client { func createHTTPClient(insecure bool) *http.Client {
return &http.Client{ return &http.Client{
Transport: httputil.WrapTransport(&http.Transport{ Transport: httputil.WrapTransport(&tls.Config{InsecureSkipVerify: insecure}),
TLSClientConfig: &tls.Config{InsecureSkipVerify: insecure},
}),
} }
} }
+14 -8
View File
@@ -418,9 +418,7 @@ func doOAuthRefresh(ctx context.Context, l *Login) (*oauth2.Token, error) {
} }
httpClient := &http.Client{ httpClient := &http.Client{
Transport: httputil.WrapTransport(&http.Transport{ Transport: httputil.WrapTransport(&tls.Config{InsecureSkipVerify: l.Insecure}),
TLSClientConfig: &tls.Config{InsecureSkipVerify: l.Insecure},
}),
} }
ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient) ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient)
@@ -448,15 +446,19 @@ func (l *Login) Client(options ...gitea.ClientOption) *gitea.Client {
os.Exit(1) os.Exit(1)
} }
httpClient := &http.Client{} // Configure transport-level timeouts so a stalled or unresponsive server
// fails fast instead of hanging forever. These bound connection setup and
// time-to-first-response-byte only, so slow-but-progressing transfers (e.g.
// large attachment uploads) are unaffected.
httpClient := &http.Client{
Transport: httputil.WrapTransport(nil),
}
if l.Insecure { if l.Insecure {
cookieJar, _ := cookiejar.New(nil) // New with nil options never returns an error cookieJar, _ := cookiejar.New(nil) // New with nil options never returns an error
httpClient = &http.Client{ httpClient = &http.Client{
Jar: cookieJar, Jar: cookieJar,
Transport: &http.Transport{ Transport: httputil.WrapTransport(&tls.Config{InsecureSkipVerify: true}),
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
} }
} }
@@ -465,6 +467,10 @@ func (l *Login) Client(options ...gitea.ClientOption) *gitea.Client {
options = append([]gitea.ClientOption{gitea.SetGiteaVersion("")}, options...) options = append([]gitea.ClientOption{gitea.SetGiteaVersion("")}, options...)
} }
// SetUserAgent is intentionally redundant with the User-Agent the WrapTransport
// transport already sets: this is the SDK's own guarantee, so the UA survives
// even if the client is ever given a transport that didn't come from WrapTransport.
// Both resolve to httputil.UserAgent(), so the duplicate Header.Set is a no-op.
options = append(options, gitea.SetToken(l.GetAccessToken()), gitea.SetHTTPClient(httpClient), gitea.SetUserAgent(httputil.UserAgent())) options = append(options, gitea.SetToken(l.GetAccessToken()), gitea.SetHTTPClient(httpClient), gitea.SetUserAgent(httputil.UserAgent()))
if debug.IsDebug() { if debug.IsDebug() {
options = append(options, gitea.SetDebugMode()) options = append(options, gitea.SetDebugMode())
+14 -6
View File
@@ -4,6 +4,7 @@
package httputil package httputil
import ( import (
"crypto/tls"
"fmt" "fmt"
"net/http" "net/http"
"runtime" "runtime"
@@ -20,12 +21,14 @@ func UserAgent() string {
return ua return ua
} }
// WrapTransport wraps an http.RoundTripper to add the User-Agent header. // WrapTransport returns tea's standard HTTP transport: an *http.Transport
func WrapTransport(base http.RoundTripper) http.RoundTripper { // preset with tea's connection / response-header timeouts (see timeoutTransport)
if base == nil { // and decorated to add the User-Agent header on every request. The supplied
base = http.DefaultTransport // tlsConfig is attached as-is (nil is fine); callers use it for insecure /
} // skip-verify logins. This is the single entry point for building a tea HTTP
return &userAgentTransport{base: base} // client transport, so the timeouts can't be accidentally omitted.
func WrapTransport(tlsConfig *tls.Config) http.RoundTripper {
return &userAgentTransport{base: timeoutTransport(tlsConfig)}
} }
type userAgentTransport struct { type userAgentTransport struct {
@@ -33,6 +36,11 @@ type userAgentTransport struct {
} }
func (t *userAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) { func (t *userAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) {
// Set the UA at the transport so every client built from WrapTransport
// identifies itself, including the non-SDK clients (oauth2 flow, token
// refresh) that never pass through the SDK's own SetUserAgent. For SDK
// clients this overlaps gitea.SetUserAgent; both use httputil.UserAgent(),
// so the duplicate Header.Set is a no-op.
req.Header.Set("User-Agent", UserAgent()) req.Header.Set("User-Agent", UserAgent())
return t.base.RoundTrip(req) return t.base.RoundTrip(req)
} }
+57
View File
@@ -0,0 +1,57 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package httputil
import (
"crypto/tls"
"net"
"net/http"
"time"
)
// Timeout values applied to every Gitea API request. These are deliberately
// connection-establishment and time-to-first-response-byte timeouts, NOT an
// overall request deadline: a large release-attachment upload can legitimately
// run for minutes, and as long as bytes keep flowing none of these fire. They
// only trip when a server accepts the connection but never (or far too slowly)
// starts responding — the "hangs forever" case from a stalled or unresponsive
// server (issue #1018).
const (
// DialTimeout bounds establishing the TCP connection.
DialTimeout = 10 * time.Second
// TLSHandshakeTimeout bounds completing the TLS handshake.
TLSHandshakeTimeout = 10 * time.Second
// ResponseHeaderTimeout bounds the wait, after the request is written, for
// the server to begin sending response headers. This is the only timeout
// that protects against a server which accepts the connection but then goes
// silent — the originally reported #1018 symptom; DialTimeout/
// TLSHandshakeTimeout do not, because the connection already succeeded.
//
// The value must clear Gitea's legitimate synchronous pre-response work.
// Profiling a self-hosted Gitea 1.24.6 (on hardware slower than gitea.com)
// showed creating a pull request that triggers conflict detection across
// ~1500 changed files takes ~10s before the first byte (3 runs: 10.06 /
// 10.08 / 10.24s); clean-diff PR creation was ~1s and large attachment
// uploads ~9ms. 120s is ~12x that measured worst case, leaving generous
// headroom for larger repos and busier servers while still failing in two
// minutes instead of hanging forever.
ResponseHeaderTimeout = 120 * time.Second
)
// timeoutTransport returns an *http.Transport configured with tea's standard
// timeouts. The supplied tlsConfig is attached as-is (callers use it for
// insecure / skip-verify logins). It is a clone of http.DefaultTransport so
// connection pooling, proxy support and HTTP/2 keep working. Callers obtain it
// through WrapTransport, which also adds the User-Agent header.
func timeoutTransport(tlsConfig *tls.Config) *http.Transport {
t := http.DefaultTransport.(*http.Transport).Clone()
t.DialContext = (&net.Dialer{
Timeout: DialTimeout,
KeepAlive: 30 * time.Second,
}).DialContext
t.TLSHandshakeTimeout = TLSHandshakeTimeout
t.ResponseHeaderTimeout = ResponseHeaderTimeout
t.TLSClientConfig = tlsConfig
return t
}
+91
View File
@@ -0,0 +1,91 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package httputil
import (
"net"
"net/http"
"testing"
"time"
)
// TestWrapTransportTimeouts verifies the transport returned by WrapTransport
// carries tea's standard timeout values, so a stalled server can't make tea
// hang forever (issue #1018).
func TestWrapTransportTimeouts(t *testing.T) {
rt := WrapTransport(nil)
uat, ok := rt.(*userAgentTransport)
if !ok {
t.Fatalf("WrapTransport returned %T, want *userAgentTransport", rt)
}
tr, ok := uat.base.(*http.Transport)
if !ok {
t.Fatalf("underlying base is %T, want *http.Transport", uat.base)
}
if tr.TLSHandshakeTimeout != TLSHandshakeTimeout {
t.Errorf("TLSHandshakeTimeout = %v, want %v", tr.TLSHandshakeTimeout, TLSHandshakeTimeout)
}
if tr.ResponseHeaderTimeout != ResponseHeaderTimeout {
t.Errorf("ResponseHeaderTimeout = %v, want %v", tr.ResponseHeaderTimeout, ResponseHeaderTimeout)
}
if tr.DialContext == nil {
t.Error("DialContext is nil, want a dialer with DialTimeout")
}
}
// newStallListener returns a listener that accepts connections, reads the
// request, then goes silent without ever sending response headers — the
// "server accepts the connection but never responds" case ResponseHeaderTimeout
// guards against. The returned closer stops the listener.
func newStallListener(t *testing.T) (addr string, closer func()) {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
done := make(chan struct{})
go func() {
for {
conn, err := ln.Accept()
if err != nil {
return
}
go func(c net.Conn) {
buf := make([]byte, 4096)
_, _ = c.Read(buf) // drain the request, then never respond
<-done // hold the connection open until the test ends
c.Close()
}(conn)
}
}()
return ln.Addr().String(), func() {
close(done)
ln.Close()
}
}
// TestResponseHeaderTimeoutFires proves a request to a server that accepts the
// connection and request but never sends response headers aborts via
// ResponseHeaderTimeout rather than hanging. It builds the transport the same
// way WrapTransport does, with a short ResponseHeaderTimeout so the test is fast.
func TestResponseHeaderTimeoutFires(t *testing.T) {
addr, closer := newStallListener(t)
defer closer()
tr := timeoutTransport(nil)
tr.ResponseHeaderTimeout = 2 * time.Second
client := &http.Client{Transport: &userAgentTransport{base: tr}}
start := time.Now()
_, err := client.Get("http://" + addr + "/")
elapsed := time.Since(start)
if err == nil {
t.Fatal("expected a timeout error from stalled server, got nil")
}
if elapsed > 10*time.Second {
t.Errorf("request took %v; ResponseHeaderTimeout did not fire", elapsed)
}
t.Logf("request failed as expected after %v: %v", elapsed, err)
}