Fix speedtest: Cloudflare 429s through proxies → 0 Mbps + false rejects
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:
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', ''),
|
||||
|
||||
@@ -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', ''),
|
||||
|
||||
Reference in New Issue
Block a user