Files
VPSMaster/proxydoctor/bandwidth.py
T

229 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Repeated, traffic-bounded HTTPS throughput measurements; not line capacity."""
from dataclasses import asdict, dataclass
import math
import os
import re
import statistics
import time
import urllib.error
import urllib.request
from .net import opener, read_limited
@dataclass(frozen=True)
class Source:
name: str
region: str
download_url: str
upload_url: str | None = None
use_range: bool = False
# Fixed official test endpoints only. No arbitrary URLs, account keys or binaries.
SOURCES = {
'cloudflare': Source('Cloudflare','Anycast','https://speed.cloudflare.com/__down',
'https://speed.cloudflare.com/__up'),
'hetzner-fsn': Source('Hetzner','DE / Falkenstein','https://fsn1-speed.hetzner.com/100MB.bin',use_range=True),
'ovh-rbx': Source('OVHcloud','FR / Roubaix','https://lil1-rbx.speedtest.network.ovh.net/files/100Mb.dat',use_range=True),
}
# Keep the slower, environment-sensitive Hetzner endpoint explicitly selectable.
DEFAULT_SOURCES = ('cloudflare','ovh-rbx')
def source_ids(selected=None):
selected = DEFAULT_SOURCES if selected is None else tuple(dict.fromkeys(selected))
if not selected or any(identifier not in SOURCES for identifier in selected):
raise ValueError('Invalid bandwidth source')
return selected
def payload_budget(rounds, sample_mib, selected=None):
return sum((2 if SOURCES[name].upload_url else 1) * (rounds * sample_mib * 1024**2 + 131072)
for name in source_ids(selected))
@dataclass
class Sample:
direction: str
size_bytes: int
elapsed_s: float = 0
mbps: float | None = None
error: str | None = None
pop: str | None = None
source: str = 'cloudflare'
def transfer(direction, size, source='cloudflare'):
if direction not in ('download', 'upload') or not 1 <= size <= 20 * 1024**2:
raise ValueError('Invalid transfer limits')
if source not in SOURCES:
raise ValueError('Invalid bandwidth source')
provider = SOURCES[source]
result = Sample(direction, size, source=source)
if direction == 'upload' and provider.upload_url is None:
result.error = 'unsupported'
return result
headers = {'User-Agent': 'ProxyDoctor/0.4.1', 'Accept-Encoding': 'identity',
'Cache-Control': 'no-cache'}
url = provider.download_url
if not provider.use_range:
url += '?bytes=' + str(size)
else:
headers['Range'] = f'bytes=0-{size-1}'
data = None
if direction == 'upload':
url = provider.upload_url
headers.pop('Range',None)
data = os.urandom(size) # Avoid compressible uploads artificially inflating results.
headers['Content-Type'] = 'application/octet-stream'
request = urllib.request.Request(url, data=data, headers=headers)
start = time.monotonic()
try:
with opener().open(request, timeout=12) as response:
ranged = direction == 'download' and provider.use_range
if ranged and response.status == 200:
# Never fall back to downloading an entire 100 MB test file.
result.error = 'range_not_supported'
return result
if response.status != (206 if ranged else 200):
result.error = 'http_' + str(response.status)
return result
if ranged:
match = re.fullmatch(r'bytes 0-(\d+)/(\d+)',response.headers.get('Content-Range',''))
length = response.headers.get('Content-Length')
if (not match or int(match[1]) != size-1 or int(match[2]) < size
or (length is not None and length != str(size))):
result.error = 'invalid_content_range'
return result
if ('text/html' in response.headers.get('Content-Type','').lower()
or response.headers.get('Content-Encoding','identity').lower() not in ('','identity')):
result.error = 'unexpected_content'
return result
ray = response.headers.get('CF-Ray', '')
pop = ray.rsplit('-', 1)[-1]
if len(pop) == 3 and pop.isalpha():
result.pop = pop.upper()
body = read_limited(response, size if direction == 'download' else 65536, start + 30)
if direction == 'download' and len(body) != size:
result.error = 'short_response'
return result
result.elapsed_s = time.monotonic() - start
if result.elapsed_s <= 0 or not math.isfinite(result.elapsed_s):
result.error = 'invalid_duration'
else:
result.mbps = round(size * 8 / result.elapsed_s / 1_000_000, 3)
except urllib.error.HTTPError as exc:
result.error = 'http_' + str(exc.code)
exc.close()
except ValueError:
result.error = 'response_too_large'
except (OSError, urllib.error.URLError):
result.error = 'request_failed'
finally:
result.elapsed_s = round(max(0, time.monotonic() - start), 4)
return result
def summarize(samples):
good = [s for s in samples if s.error is None and s.mbps is not None and math.isfinite(s.mbps) and s.mbps > 0]
speeds = [s.mbps for s in good]
median = statistics.median(speeds) if speeds else None
spread = (max(speeds) - min(speeds)) / median if median else None
enough = len(good) >= 3 and len(good) == len(samples)
return {'successful': len(good), 'attempted': len(samples),
'median_mbps': round(median, 3) if median is not None else None,
'min_mbps': min(speeds) if speeds else None, 'max_mbps': max(speeds) if speeds else None,
'spread_percent': round(spread * 100, 1) if spread is not None else None,
'quality': 'unavailable' if not speeds else 'insufficient' if not enough else
'short_samples' if any(s.elapsed_s < 1 for s in good) else
'variable' if spread > .3 else 'consistent',
'samples': [asdict(s) for s in samples]}
def measure(rounds=3, sample_mib=4, on_sample=None, source='cloudflare'):
if not 3 <= rounds <= 8 or not 1 <= sample_mib <= 20:
raise ValueError('Invalid benchmark limits')
source_ids((source,))
provider = SOURCES[source]
report = {'source':source,'provider':provider.name,'region':provider.region,
'download_url':provider.download_url,'upload_supported':provider.upload_url is not None,
'method': 'sequential_https_wall_time',
'payload_budget_bytes': payload_budget(rounds,sample_mib,(source,)),
'warmups': {}, 'directions': {}}
def sample_transfer(direction,size):
# Preserve the original single-source Python API.
return transfer(direction,size) if source=='cloudflare' else transfer(direction,size,source=source)
for direction in (('download','upload') if provider.upload_url else ('download',)):
warmup = sample_transfer(direction, 131072)
report['warmups'][direction] = asdict(warmup)
samples = []
for number in range(1, rounds + 1):
# Do not hammer an endpoint that explicitly refuses or rate-limits us.
previous = samples[-1] if samples else warmup
if previous.error in ('http_403','http_429','provider_refused'):
sample = Sample(direction,sample_mib*1024**2,error='provider_refused',source=source)
elif previous.error in ('range_not_supported','invalid_content_range','unexpected_content','source_unavailable'):
sample = Sample(direction,sample_mib*1024**2,error=previous.error,source=source)
elif samples and previous.error and (samples[-2].error if len(samples)>1 else warmup.error):
sample = Sample(direction,sample_mib*1024**2,error='source_unavailable',source=source)
else:
sample = sample_transfer(direction,sample_mib*1024**2)
samples.append(sample)
if on_sample:
on_sample(direction, number, rounds, sample)
report['directions'][direction] = summarize(samples)
return report
def measure_many(rounds=3, sample_mib=4, sources=None, on_source=None, on_sample=None):
if not 3 <= rounds <= 8 or not 1 <= sample_mib <= 20:
raise ValueError('Invalid benchmark limits')
selected=source_ids(sources)
report={'schema_version':2,'method':'sequential_https_wall_time',
'payload_budget_bytes':payload_budget(rounds,sample_mib,selected),'sources':{}}
for identifier in selected:
if on_source: on_source(identifier,'start',None)
callback=(lambda *args: on_sample(identifier,*args)) if on_sample else None
result=measure(rounds,sample_mib,callback,source=identifier)
report['sources'][identifier]=result
if on_source: on_source(identifier,'complete',result)
return report
def is_complete(report):
return all(result['quality']=='consistent' for source in report['sources'].values()
for result in source['directions'].values())
def run_console(lang='zh',color='auto',rounds=3,sample_mib=4,sources=None):
from .console import Console
out=Console(lang,color);tr=out.text
selected=source_ids(sources)
budget=payload_budget(rounds,sample_mib,selected)/1024**2
print(tr(f' {len(selected)} 个来源 · 每方向 {rounds} 轮 × {sample_mib} MiB · 请求载荷最多 {budget:g} MiB',
f' {len(selected)} sources · {rounds} rounds × {sample_mib} MiB/direction · requested payload ≤ {budget:g} MiB'),flush=True)
def source_output(identifier,event,result):
provider=SOURCES[identifier]
if event=='start':
print('\n'+out.paint(f'{provider.name} · {provider.region}','title'),flush=True)
if not provider.upload_url: out.row(tr('上传','Upload'),tr('不支持(该来源仅提供下载)','Not supported (download-only source)'),'unknown')
return
for direction,summary in result['directions'].items():
label=tr('下载统计','Download summary') if direction=='download' else tr('上传统计','Upload summary')
median=summary['median_mbps']
value=tr('无有效样本','No valid samples') if median is None else tr(
f"中位 {median:.2f} · 范围 {summary['min_mbps']:.2f}–{summary['max_mbps']:.2f} Mbps · {summary['successful']}/{summary['attempted']} 成功",
f"Median {median:.2f} · Range {summary['min_mbps']:.2f}–{summary['max_mbps']:.2f} Mbps · {summary['successful']}/{summary['attempted']} OK")
out.row(label,value,'ok' if summary['quality']=='consistent' else 'unknown')
note={'short_samples':tr('样本短于 1 秒,容量未知','Samples below 1s; capacity unknown'),
'variable':tr('轮间波动较大','High variation between rounds'),
'insufficient':tr('有效轮次不足','Insufficient successful rounds')}.get(summary['quality'])
if note: out.row('',note,'unknown')
def sample_output(identifier,direction,number,total,sample):
label=tr('下载','Download') if direction=='download' else tr('上传','Upload')
value=f'{sample.mbps:.2f} Mbps · {sample.elapsed_s:.2f}s' if sample.mbps is not None else tr('未成功','Failed')+' · '+str(sample.error)
if sample.pop: value+=' · '+sample.pop
out.row(f'{label} {number}/{total}',value,'ok' if sample.mbps is not None else 'unknown')
return measure_many(rounds,sample_mib,selected,source_output,sample_output)