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
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,36 @@

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

# 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
93 changes: 80 additions & 13 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 @@ -79,10 +81,13 @@ class TestTimers(NamedTuple):
def to_speeds(self, test: TestSpec) -> list[int]:
"""Compute the test speeds in bits per second from its type and size."""
if test.type == TestType.Up:
return [int(test.bits / server_time) for server_time in self.server]
return [
int(test.bits / st) if st > 0 else int(test.bits / max(ft, 1e-6))
for st, ft in zip(self.server, self.full)
]
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 +176,47 @@ 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."""
# Split by comma to get individual metrics
metrics = [m.strip() for m in header_value.split(",") if m.strip()]
for metric in metrics:
# Split by semicolon to get parameters
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:
# Strip quotes and extra characters if present
# Users might have malformed headers like dur=0"
clean_value = value.strip().strip('"').strip("'")
return float(clean_value) / 1e3
except (ValueError, TypeError):
continue
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 @@ -200,13 +239,35 @@ 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
)
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)
return coll

def _sprint(
Expand All @@ -232,6 +293,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 +307,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.5-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.5-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