Fix speedtest: Cloudflare 429s through proxies → 0 Mbps + false rejects
CI / validate (push) Successful in 14s
CI / images (deploy/Dockerfile.api, api) (push) Successful in 52s
CI / images (deploy/Dockerfile.checker, checker) (push) Successful in 38s
CI / images (deploy/Dockerfile.web, web) (push) Successful in 22s

Root cause (found by probing 15 live proxies): speed.cloudflare.com/__down
returns HTTP 429 (rate-limited) through shared proxy exit IPs, and its 1-byte
429 body was measured as "0 Mbps" with no error — so the speed gate then
rejected otherwise-fast proxies. Plain-HTTP CDN mirrors (cachefly etc.) download
reliably through the same proxies at 40-80 Mbps.

Fix:
- Default speed-test URL is now http://cachefly.cachefly.net/10mb.test.
- The checker tries the configured URL then hardcoded fallbacks (cachefly, tele2,
  thinkbroadband, cloudflare). A host-side failure (dial error, HTTP 429/non-200,
  empty body) moves to the next URL; a completed download — even a slow one — is
  taken as the proxy's real speed (the proxy is the bottleneck).
- Only a 200 response counts; a 429/non-200 is a host failure, not a 0-Mbps proxy.

Verified end-to-end: a speedtest-ON cycle now yields valid proxies that all carry
a real speed (session had 7 valid, 41-73 Mbps) instead of everything reading 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TtR4PP2JM9KadaBkhPGAE
This commit is contained in:
omar
2026-07-02 16:22:04 +03:00
co-authored by Claude Opus 4.8
parent 02a16d6fab
commit 40e6938254
4 changed files with 73 additions and 11 deletions
+70 -8
View File
@@ -140,7 +140,7 @@ func (c *Checker) Check(ctx context.Context, canonicalURL string, cfg Config) Re
return res
}
if cfg.SpeedTestEnabled && cfg.SpeedTestURL != "" {
if cfg.SpeedTestEnabled {
mbps, serr := c.speedTest(ctx, up, cfg)
res.SpeedMbps = mbps
if serr != nil {
@@ -215,26 +215,88 @@ func (c *Checker) ping(ctx context.Context, up *upstream.Upstream, cfg Config) R
return res
}
// speedTest downloads the target through the proxy and returns Mbps.
// DefaultSpeedTestURLs are proxy-friendly fallbacks tried in addition to the
// admin-configured URL. Cloudflare's endpoint frequently returns HTTP 429
// through shared proxy exit IPs, so plain-HTTP CDN mirrors come first and
// Cloudflare is the last resort.
var DefaultSpeedTestURLs = []string{
"http://cachefly.cachefly.net/10mb.test",
"http://speedtest.tele2.net/10MB.zip",
"http://ipv4.download.thinkbroadband.com/10MB.zip",
"https://speed.cloudflare.com/__down?bytes=10000000",
}
// speedTest measures throughput through the proxy. It tries the configured URL
// then the fallbacks: a host-level failure (dial error, HTTP 429/non-200, empty
// body) moves to the next URL, but a completed download — even a slow one — is
// taken as the proxy's real speed and returned as-is (the proxy is the
// bottleneck, so trying other hosts wouldn't help).
func (c *Checker) speedTest(ctx context.Context, up *upstream.Upstream, cfg Config) (float64, error) {
var lastErr error
for _, url := range speedTestURLs(cfg.SpeedTestURL) {
mbps, retryable, err := c.speedTestOne(ctx, up, url, cfg)
if err == nil {
return mbps, nil
}
lastErr = err
if !retryable || ctx.Err() != nil {
return 0, err
}
}
if lastErr == nil {
lastErr = fmt.Errorf("no speedtest url")
}
return 0, lastErr
}
// speedTestOne downloads url through the proxy. retryable=true means the failure
// is host-side (worth trying another URL); retryable=false means we got a usable
// measurement or a proxy-side stall.
func (c *Checker) speedTestOne(ctx context.Context, up *upstream.Upstream, url string, cfg Config) (mbps float64, retryable bool, err error) {
client := c.proxyHTTPClient(up, cfg.DialTimeout, cfg.SpeedTestTimeout)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, cfg.SpeedTestURL, nil)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return 0, err
return 0, false, err
}
req.Header.Set("User-Agent", "curl/8.0")
start := time.Now()
resp, err := client.Do(req)
if err != nil {
return 0, err
return 0, true, err // dial/connection failure → try next host
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
return 0, true, fmt.Errorf("status %d", resp.StatusCode) // 429 etc → try next host
}
n, _ := io.Copy(io.Discard, resp.Body)
elapsed := time.Since(start).Seconds()
if elapsed <= 0 || n <= 0 {
return 0, fmt.Errorf("empty body or zero elapsed")
if n <= 0 || elapsed <= 0 {
return 0, true, fmt.Errorf("empty body") // nothing downloaded → try next host
}
return float64(n) * 8 / 1024 / 1024 / elapsed, nil
return float64(n) * 8 / 1024 / 1024 / elapsed, false, nil
}
// speedTestURLs returns the configured URL first, then the fallbacks, de-duped.
func speedTestURLs(primary string) []string {
seen := make(map[string]struct{})
out := make([]string, 0, len(DefaultSpeedTestURLs)+1)
add := func(u string) {
if u == "" {
return
}
if _, ok := seen[u]; ok {
return
}
seen[u] = struct{}{}
out = append(out, u)
}
add(primary)
for _, u := range DefaultSpeedTestURLs {
add(u)
}
return out
}
func extractIP(s string) string {
+1 -1
View File
@@ -51,7 +51,7 @@ func Default() Settings {
MaxLatencyMs: 1000,
MinSpeedMbps: 3,
SpeedTestEnabled: true,
SpeedTestURL: "https://speed.cloudflare.com/__down?bytes=10000000",
SpeedTestURL: "http://cachefly.cachefly.net/10mb.test",
GeoIPDBURL: "https://cdn.jsdelivr.net/npm/@ip-location-db/geolite2-geo-whois-asn-country-mmdb/geolite2-geo-whois-asn-country.mmdb",
AutoEnabled: true,
TelegramNotifyFinish: true,
+1 -1
View File
@@ -168,7 +168,7 @@ INSERT INTO settings (key, value) VALUES
('max_latency_ms', '1000'),
('min_speed_mbps', '3'),
('speedtest_enabled', 'true'),
('speedtest_url', 'https://speed.cloudflare.com/__down?bytes=10000000'),
('speedtest_url', 'http://cachefly.cachefly.net/10mb.test'),
('geoip_db_url', 'https://cdn.jsdelivr.net/npm/@ip-location-db/geolite2-geo-whois-asn-country-mmdb/geolite2-geo-whois-asn-country.mmdb'),
('auto_enabled', 'true'),
('telegram_bot_token', ''),
+1 -1
View File
@@ -165,7 +165,7 @@ INSERT INTO settings (key, value) VALUES
('max_latency_ms', '1000'),
('min_speed_mbps', '3'),
('speedtest_enabled', 'true'),
('speedtest_url', 'https://speed.cloudflare.com/__down?bytes=10000000'),
('speedtest_url', 'http://cachefly.cachefly.net/10mb.test'),
('geoip_db_url', 'https://cdn.jsdelivr.net/npm/@ip-location-db/geolite2-geo-whois-asn-country-mmdb/geolite2-geo-whois-asn-country.mmdb'),
('auto_enabled', 'true'),
('telegram_bot_token', ''),