Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,42 @@

All notable changes to this project will be documented in this file.

# cloudflarepycli@2.1.6-rc

## Change

- Fix incorrect upload speed calculation. The Cloudflare Speed Test __up endpoint now returns a new response header: cf-meta-upload-bytes — the actual number of bytes the server accepted for the upload (in bytes).

# cloudflarepycli@2.1.5-rc

## Change

- fix: Return 0.0 and log a debug message when the Server-Timing header lacks a valid duration, instead of raising a ValueError.

# cloudflarepycli@2.1.4-rc

## Change

- feat: Enhance `Server-Timing` header parsing for robustness and add checks for empty test samples.

# cloudflarepycli@2.1.3-rc

## Change

- Fix ZeroDivisionError

# cloudflarepycli@2.1.2-rc

## Change

- fix response timing processing

# cloudflarepycli@2.1.1-rc

## Change

- add referer header to fix 400 error

# cloudflarepycli@2.1.0

## Change
Expand Down
128 changes: 113 additions & 15 deletions cfspeedtest/cloudflare.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@

import requests

from cfspeedtest.version import __version__

log = logging.getLogger("cfspeedtest")


Expand Down Expand Up @@ -75,14 +77,30 @@ class TestTimers(NamedTuple):
"""The times taken to process the requests as reported by the worker."""
request: list[float]
"""The internal client times elapsed to complete the requests."""
upload_bytes: list[int] | None = None
"""Per-iteration server-accepted upload bytes from cf-meta-upload-bytes."""

def to_speeds(self, test: TestSpec) -> list[int]:
"""Compute the test speeds in bits per second from its type and size."""
"""Compute the test speeds in bits per second from its type and size.

For uploads, uses the server-accepted byte count from the
cf-meta-upload-bytes response header when available, falling back to
the requested payload size.
"""
if test.type == TestType.Up:
return [int(test.bits / server_time) for server_time in self.server]
return [
int((ub * 8 if ub else test.bits) / st)
if st > 0
else int((ub * 8 if ub else test.bits) / max(ft, 1e-6))
for st, ft, ub in zip(
self.server,
self.full,
self.upload_bytes or [None] * len(self.server),
)
]
return [
int(test.bits / (full_time - server_time))
for full_time, server_time in zip(self.full, self.server)
int(test.bits / (ft - st)) if (ft - st) > 0 else int(test.bits / max(ft, 1e-6))
for ft, st in zip(self.full, self.server)
]

def to_latencies(self) -> list[float]:
Expand Down Expand Up @@ -171,13 +189,53 @@ def __init__( # noqa: D417

self.tests = tests
self.request_sess = requests.Session()
self.request_sess.headers.update(
{
"Referer": "https://speed.cloudflare.com/",
"User-Agent": f"cloudflarepycli/{__version__}",
}
)
self.timeout = timeout

@staticmethod
def _parse_server_timing(header_value: str) -> float:
"""Parse the server timing header into seconds."""
worker_dur: float | None = None
fallback_dur: float | None = None
metrics = [m.strip() for m in header_value.split(",") if m.strip()]
for metric in metrics:
metric_name = metric.split(";")[0].strip().lower()
params = [p.strip() for p in metric.split(";") if p.strip()]
for param in params:
if "=" not in param:
continue
name, value = param.split("=", 1)
if name.strip().lower() == "dur":
try:
clean_value = value.strip().strip('"').strip("'")
dur = float(clean_value) / 1e3
except (ValueError, TypeError):
continue
if metric_name == "cfSpeedWorker":
worker_dur = dur
fallback_dur = dur
if worker_dur is not None:
return worker_dur
if fallback_dur is not None:
return fallback_dur
log.debug(
"Server-Timing header did not include a valid duration: %s. Falling back to 0.0",
header_value,
)
return 0.0

def metadata(self) -> TestMetadata:
"""Retrieve test location code, IP address, ISP, city, and region."""
result_data: dict[str, str] = self.request_sess.get(
"https://speed.cloudflare.com/meta"
).json()
response = self.request_sess.get(
"https://speed.cloudflare.com/meta", timeout=self.timeout
)
response.raise_for_status()
result_data: dict[str, str] = response.json()
return TestMetadata(
result_data.get("clientIp"),
result_data.get("asOrganization"),
Expand All @@ -189,6 +247,7 @@ def metadata(self) -> TestMetadata:
def run_test(self, test: TestSpec) -> TestTimers:
"""Run a test specification iteratively and collect timers."""
coll = TestTimers([], [], [])
upload_bytes: list[int] = []
url = f"https://speed.cloudflare.com/__down?bytes={test.size}"
data = None
if test.type == TestType.Up:
Expand All @@ -200,14 +259,47 @@ def run_test(self, test: TestSpec) -> TestTimers:
r = self.request_sess.request(
test.type.value, url, data=data, timeout=self.timeout
)
coll.full.append(time.time() - start)
coll.server.append(
float(r.headers["Server-Timing"].split("=")[1].split(",")[0]) / 1e3
)
coll.request.append(
r.elapsed.seconds + r.elapsed.microseconds / 1e6
)
return coll
r.raise_for_status()

if test.type == TestType.Down and len(r.content) < test.size:
raise ValueError(
"Download response size smaller than expected"
)

full_time = time.time() - start
server_timing_header = r.headers.get("Server-Timing")
if not server_timing_header:
raise ValueError("Missing Server-Timing header")

server_time = self._parse_server_timing(server_timing_header)
request_time = r.elapsed.total_seconds()

if server_time >= full_time:
log.warning(
"Server timing >= full time (server=%s, full=%s); clamping.",
server_time,
full_time,
)
server_time = max(full_time - 1e-6, 0.0)

if server_time >= request_time:
server_time = max(request_time - 1e-6, 0.0)

coll.full.append(full_time)
coll.server.append(server_time)
coll.request.append(request_time)

if test.type == TestType.Up:
ub = r.headers.get("cf-meta-upload-bytes")
if ub is not None:
try:
upload_bytes.append(int(ub))
except (ValueError, TypeError):
pass

return TestTimers(
coll.full, coll.server, coll.request, upload_bytes or None
)

def _sprint(
self, label: str, result: TestResult, *, meta: bool = False
Expand All @@ -232,6 +324,9 @@ def run_all(self, *, megabits: bool = False) -> SuiteResults:

if test.name == "latency":
latencies = timers.to_latencies()
if not latencies:
log.error("No samples recorded for latency test")
continue
jitter = timers.jitter_from(latencies)
if jitter:
jitter = round(jitter, 2)
Expand All @@ -243,6 +338,9 @@ def run_all(self, *, megabits: bool = False) -> SuiteResults:
continue

speeds = timers.to_speeds(test)
if not speeds:
log.error("No samples recorded for test: %s", test.name)
continue
data[test.type.name.lower()].extend(speeds)
self._sprint(
*_with_units(
Expand Down
2 changes: 1 addition & 1 deletion cfspeedtest/version.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""Current version of cfspeedtest."""

__version__ = "2.1.0"
__version__ = "2.1.6-rc"
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "cloudflarepycli"
version = "2.1.0"
version = "2.1.6-rc"
description = "Python CLI and utiltiies for retrieving network performance statistics."
authors = [{ name = "Tom Evslin", email = "tevslin@gmail.com"}, { name = "Martin Brose", email = "nitramesorb@gmail.com"}]
readme = "README.md"
Expand Down