Files
SpotiFLAC-Mobile/go_backend/httputil_utls.go
T
zarzet 2fa4aa5b70 perf(network): mobile stability and data-usage fixes from network audit
Stability:
- transient timeouts no longer classified as ISP blocking; they fall
  through to retry backoff (hard blocks - DNS/RST/cert - still abort)
- connectivity change now closes idle Go connections in every network
  mode (debounced), so pooled sockets from the old interface are not
  reused after a wifi/cellular handoff
- download body reads get a 60s stall watchdog that cancels and
  surfaces a retryable network error instead of hanging; distinct
  from user cancellation
- ResponseHeaderTimeout 45s on all transports; IdleConnTimeout 90->60s;
  dial timeout 30->10s; retry backoff gains full jitter; Retry-After
  honored on 5xx and Deezer 429

Data usage / speed:
- cover downloads deduplicated with singleflight plus a 24MB/15min
  in-memory cache keyed by final URL (album batches fetched the same
  1800px cover once per track before)
- song.link availability cached (30min positive / 5min negative)
  in front of the 9-req/min rate limiter
- uTLS Cloudflare path now pools one HTTP/2 connection per host with
  a shared TLS session cache instead of a full handshake per request

Deezer artist track-count N+1 kept: counts feed the discography
download UI and are already amortized by the artist TTL cache.
2026-07-13 08:39:05 +07:00

216 lines
5.3 KiB
Go

//go:build !ios
package gobackend
import (
"context"
"io"
"net"
"net/http"
"net/url"
"strings"
"sync"
utls "github.com/refraction-networking/utls"
"golang.org/x/net/http2"
)
// utlsSessionCache is shared by every uTLS handshake so TLS 1.3 tickets enable
// resumption (fewer round-trips) across requests and hosts.
var utlsSessionCache = utls.NewLRUClientSessionCache(0)
// utlsTransport dials with a Chrome TLS fingerprint and pools one healthy HTTP/2
// connection per host, re-dialing when it dies (e.g. after a network switch).
type utlsTransport struct {
dialer *net.Dialer
h2 *http2.Transport
mu sync.Mutex
conns map[string]*http2.ClientConn
}
func newUTLSTransport() *utlsTransport {
return &utlsTransport{
dialer: &net.Dialer{
Timeout: 10 * Second,
KeepAlive: 30 * Second,
},
h2: &http2.Transport{},
conns: make(map[string]*http2.ClientConn),
}
}
func (t *utlsTransport) RoundTrip(req *http.Request) (*http.Response, error) {
if req.URL.Scheme != "https" {
return sharedTransport.RoundTrip(req)
}
host := req.URL.Hostname()
addr := net.JoinHostPort(host, t.getPort(req.URL))
if cc := t.cachedConn(addr); cc != nil {
resp, err := cc.RoundTrip(req)
if err != nil {
t.invalidate(addr, cc)
}
return resp, err
}
tlsConn, proto, err := t.dial(req.Context(), host, addr)
if err != nil {
return nil, err
}
if proto != "h2" {
// HTTP/1.1: single-use conn closed once the body is drained.
transport := &http.Transport{
DialTLSContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return tlsConn, nil
},
DisableKeepAlives: true,
}
return transport.RoundTrip(req)
}
cc, err := t.h2.NewClientConn(tlsConn)
if err != nil {
tlsConn.Close()
return nil, err
}
cc = t.storeConn(addr, cc)
return cc.RoundTrip(req)
}
// dial opens a TCP connection and completes the Chrome-fingerprint TLS handshake,
// returning the connection and negotiated ALPN protocol.
func (t *utlsTransport) dial(ctx context.Context, host, addr string) (*utls.UConn, string, error) {
conn, err := t.dialer.DialContext(ctx, "tcp", addr)
if err != nil {
return nil, "", err
}
opts := GetNetworkCompatibilityOptions()
tlsConn := utls.UClient(conn, &utls.Config{
RootCAs: supplementalRootCAs(),
InsecureSkipVerify: opts.InsecureTLS,
ServerName: host,
NextProtos: []string{"h2", "http/1.1"},
ClientSessionCache: utlsSessionCache,
}, utls.HelloChrome_Auto)
if err := tlsConn.Handshake(); err != nil {
conn.Close()
return nil, "", err
}
return tlsConn, tlsConn.ConnectionState().NegotiatedProtocol, nil
}
func (t *utlsTransport) cachedConn(addr string) *http2.ClientConn {
t.mu.Lock()
defer t.mu.Unlock()
if cc := t.conns[addr]; cc != nil && cc.CanTakeNewRequest() {
return cc
}
return nil
}
func (t *utlsTransport) invalidate(addr string, cc *http2.ClientConn) {
t.mu.Lock()
if t.conns[addr] == cc {
delete(t.conns, addr)
}
t.mu.Unlock()
}
// storeConn caches cc, but if a concurrent dial already cached a healthy conn for
// addr it discards the freshly built cc (no in-flight requests) and returns the
// existing one, avoiding a leaked connection.
func (t *utlsTransport) storeConn(addr string, cc *http2.ClientConn) *http2.ClientConn {
t.mu.Lock()
defer t.mu.Unlock()
if existing := t.conns[addr]; existing != nil && existing.CanTakeNewRequest() {
cc.Close()
return existing
}
t.conns[addr] = cc
return cc
}
func (t *utlsTransport) getPort(u *url.URL) string {
if u.Port() != "" {
return u.Port()
}
if u.Scheme == "https" {
return "443"
}
return "80"
}
var cloudflareBypassTransport = newUTLSTransport()
var cloudflareBypassClient = &http.Client{
Transport: cloudflareBypassTransport,
Timeout: DefaultTimeout,
}
func GetCloudflareBypassClient() *http.Client {
return cloudflareBypassClient
}
func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
req.Header.Set("User-Agent", userAgentForURL(req.URL))
resp, err := sharedClient.Do(req)
if err == nil {
if resp.StatusCode == 403 || resp.StatusCode == 503 {
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr == nil {
bodyStr := strings.ToLower(string(body))
cloudflareMarkers := []string{
"cloudflare", "cf-ray", "checking your browser",
"please wait", "ddos protection", "ray id",
"enable javascript", "challenge-platform",
}
isCloudflare := false
for _, marker := range cloudflareMarkers {
if strings.Contains(bodyStr, marker) {
isCloudflare = true
break
}
}
if isCloudflare {
LogDebug("HTTP", "Cloudflare detected, retrying with Chrome TLS fingerprint...")
reqCopy := req.Clone(req.Context())
reqCopy.Header.Set("User-Agent", userAgentForURL(reqCopy.URL))
return cloudflareBypassClient.Do(reqCopy)
}
}
return &http.Response{
Status: resp.Status,
StatusCode: resp.StatusCode,
Header: resp.Header,
Body: io.NopCloser(strings.NewReader(string(body))),
}, nil
}
return resp, nil
}
if isTLSHandshakeOrResetError(err) {
LogDebug("HTTP", "TLS error detected, retrying with Chrome TLS fingerprint: %v", err)
reqCopy := req.Clone(req.Context())
reqCopy.Header.Set("User-Agent", userAgentForURL(reqCopy.URL))
return cloudflareBypassClient.Do(reqCopy)
}
CheckAndLogISPBlocking(err, req.URL.String(), "HTTP")
return nil, err
}