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.
This commit is contained in:
zarzet
2026-07-13 08:39:05 +07:00
parent 8579f68554
commit 2fa4aa5b70
12 changed files with 682 additions and 44 deletions
+61 -8
View File
@@ -65,14 +65,15 @@ var (
var sharedTransport = &http.Transport{
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
Timeout: 10 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
MaxConnsPerHost: 20,
IdleConnTimeout: 90 * time.Second,
IdleConnTimeout: 60 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ResponseHeaderTimeout: 45 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
DisableKeepAlives: false,
ForceAttemptHTTP2: true,
@@ -84,14 +85,15 @@ var sharedTransport = &http.Transport{
var extensionAPITransport = &http.Transport{
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
Timeout: 10 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
MaxConnsPerHost: 20,
IdleConnTimeout: 90 * time.Second,
IdleConnTimeout: 60 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ResponseHeaderTimeout: 45 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
DisableKeepAlives: false,
ForceAttemptHTTP2: true,
@@ -103,14 +105,15 @@ var extensionAPITransport = &http.Transport{
var metadataTransport = &http.Transport{
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
Timeout: 10 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
MaxIdleConns: 30,
MaxIdleConnsPerHost: 5,
MaxConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
IdleConnTimeout: 60 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ResponseHeaderTimeout: 45 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
DisableKeepAlives: false,
ForceAttemptHTTP2: true,
@@ -276,7 +279,7 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
if err != nil {
lastErr = err
if CheckAndLogISPBlocking(err, reqCopy.URL.String(), "HTTP") {
if isHardConnectivityBlock(err) {
return nil, WrapErrorWithISPCheck(err, reqCopy.URL.String(), "HTTP")
}
@@ -331,6 +334,9 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
if resp.StatusCode >= 500 {
resp.Body.Close()
if retryAfter := getRetryAfterDuration(resp); retryAfter > 0 {
delay = retryAfter
}
lastErr = fmt.Errorf("server error: HTTP %d", resp.StatusCode)
if attempt < config.MaxRetries {
GoLog("[HTTP] Server error %d, retrying in %v...\n", resp.StatusCode, delay)
@@ -346,9 +352,20 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
return nil, fmt.Errorf("request failed after %d retries: %w", config.MaxRetries+1, lastErr)
}
// jitterFloat returns a fraction in [0,1); overridable in tests for
// deterministic backoff assertions.
var jitterFloat = rand.Float64
func calculateNextDelay(currentDelay time.Duration, config RetryConfig) time.Duration {
nextDelay := time.Duration(float64(currentDelay) * config.BackoffFactor)
return min(nextDelay, config.MaxDelay)
capped := min(nextDelay, config.MaxDelay)
// Full jitter: spread retries between InitialDelay and the capped
// exponential ceiling to avoid synchronized thundering-herd retries.
if capped <= config.InitialDelay {
return capped
}
span := capped - config.InitialDelay
return config.InitialDelay + time.Duration(jitterFloat()*float64(span))
}
// Returns 0 if the header is missing or invalid so callers can keep their
@@ -524,6 +541,42 @@ func isTLSHandshakeOrResetError(err error) bool {
return false
}
// isHardConnectivityBlock reports transport failures that signal an active
// block (DNS not found, connection refused/reset, TLS/cert MITM) and should
// abort retries immediately. Timeouts and deadline-exceeded are treated as
// transient and excluded so they fall through to normal retry backoff.
func isHardConnectivityBlock(err error) bool {
if err == nil {
return false
}
var urlErr *url.Error
if errors.As(err, &urlErr) && urlErr.Timeout() {
return false
}
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) {
return dnsErr.IsNotFound
}
var opErr *net.OpError
if errors.As(err, &opErr) {
if opErr.Timeout() {
return false
}
var errno syscall.Errno
if errors.As(opErr.Err, &errno) {
switch errno {
case syscall.ECONNREFUSED, syscall.ECONNRESET:
return true
}
}
}
return isTLSHandshakeOrResetError(err)
}
func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
if err == nil {
return nil