From a52f67663a0c87b88e50862b75820a5b03cbebe0 Mon Sep 17 00:00:00 2001 From: DigitallyRefined <129616584+DigitallyRefined@users.noreply.github.com> Date: Sun, 7 Dec 2025 10:48:21 +0000 Subject: [PATCH 1/7] Cloudflare speed test now seems to require a Referer header --- cfspeedtest/cloudflare.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cfspeedtest/cloudflare.py b/cfspeedtest/cloudflare.py index 463001b..af353a8 100644 --- a/cfspeedtest/cloudflare.py +++ b/cfspeedtest/cloudflare.py @@ -176,7 +176,8 @@ def __init__( # noqa: D417 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" + "https://speed.cloudflare.com/meta", + headers={"Referer": "https://speed.cloudflare.com/"} ).json() return TestMetadata( result_data.get("clientIp"), From f8199b37b4eeed992791181d1dc3a0522936470a Mon Sep 17 00:00:00 2001 From: DigitallyRefined <129616584+DigitallyRefined@users.noreply.github.com> Date: Thu, 5 Feb 2026 14:23:07 +0000 Subject: [PATCH 2/7] Fix response timing processing --- CHANGELOG.md | 12 +++++++ cfspeedtest/cloudflare.py | 66 ++++++++++++++++++++++++++++++++------- cfspeedtest/version.py | 2 +- pyproject.toml | 2 +- 4 files changed, 69 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e2d771c..3b962d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ All notable changes to this project will be documented in this file. +# 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 diff --git a/cfspeedtest/cloudflare.py b/cfspeedtest/cloudflare.py index af353a8..7a13c1c 100644 --- a/cfspeedtest/cloudflare.py +++ b/cfspeedtest/cloudflare.py @@ -14,6 +14,8 @@ import requests +from cfspeedtest.version import __version__ + log = logging.getLogger("cfspeedtest") @@ -171,14 +173,34 @@ 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.""" + parts = [part.strip() for part in header_value.split(",") if part.strip()] + for part in parts: + if "dur=" in part: + duration = part.split("dur=")[-1] + return float(duration) / 1e3 + if "=" in part: + duration = part.split("=")[-1] + return float(duration) / 1e3 + raise ValueError("Server-Timing header did not include a duration") + 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", - headers={"Referer": "https://speed.cloudflare.com/"} - ).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"), @@ -201,13 +223,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( diff --git a/cfspeedtest/version.py b/cfspeedtest/version.py index a2104ff..b858a73 100644 --- a/cfspeedtest/version.py +++ b/cfspeedtest/version.py @@ -1,3 +1,3 @@ """Current version of cfspeedtest.""" -__version__ = "2.1.0" +__version__ = "2.1.2-rc" diff --git a/pyproject.toml b/pyproject.toml index f9d0f71..6f51e6c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "cloudflarepycli" -version = "2.1.0" +version = "2.1.2-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" From 0bc019c78038d678ee6f95e97e31b4aebe0a0f05 Mon Sep 17 00:00:00 2001 From: DigitallyRefined <129616584+DigitallyRefined@users.noreply.github.com> Date: Thu, 5 Mar 2026 16:09:34 +0000 Subject: [PATCH 3/7] Fix ZeroDivisionError --- CHANGELOG.md | 6 ++++++ cfspeedtest/cloudflare.py | 9 ++++++--- cfspeedtest/version.py | 2 +- pyproject.toml | 2 +- 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b962d3..c2e55f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to this project will be documented in this file. +# cloudflarepycli@2.1.3-rc + +## Change + +- Fix ZeroDivisionError + # cloudflarepycli@2.1.2-rc ## Change diff --git a/cfspeedtest/cloudflare.py b/cfspeedtest/cloudflare.py index 7a13c1c..de57974 100644 --- a/cfspeedtest/cloudflare.py +++ b/cfspeedtest/cloudflare.py @@ -81,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]: diff --git a/cfspeedtest/version.py b/cfspeedtest/version.py index b858a73..4eea254 100644 --- a/cfspeedtest/version.py +++ b/cfspeedtest/version.py @@ -1,3 +1,3 @@ """Current version of cfspeedtest.""" -__version__ = "2.1.2-rc" +__version__ = "2.1.3-rc" diff --git a/pyproject.toml b/pyproject.toml index 6f51e6c..5ac3631 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "cloudflarepycli" -version = "2.1.2-rc" +version = "2.1.3-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" From 3057b23130aa6f46478bc66ef44a6d25f4968f09 Mon Sep 17 00:00:00 2001 From: DigitallyRefined <129616584+DigitallyRefined@users.noreply.github.com> Date: Thu, 12 Mar 2026 14:56:28 +0000 Subject: [PATCH 4/7] feat: Enhance `Server-Timing` header parsing for robustness and add checks for empty test samples. --- cfspeedtest/cloudflare.py | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/cfspeedtest/cloudflare.py b/cfspeedtest/cloudflare.py index de57974..d9a8c28 100644 --- a/cfspeedtest/cloudflare.py +++ b/cfspeedtest/cloudflare.py @@ -187,15 +187,24 @@ def __init__( # noqa: D417 @staticmethod def _parse_server_timing(header_value: str) -> float: """Parse the server timing header into seconds.""" - parts = [part.strip() for part in header_value.split(",") if part.strip()] - for part in parts: - if "dur=" in part: - duration = part.split("dur=")[-1] - return float(duration) / 1e3 - if "=" in part: - duration = part.split("=")[-1] - return float(duration) / 1e3 - raise ValueError("Server-Timing header did not include a duration") + # 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 + raise ValueError("Server-Timing header did not include a valid duration") def metadata(self) -> TestMetadata: """Retrieve test location code, IP address, ISP, city, and region.""" @@ -280,6 +289,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) @@ -291,6 +303,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( From eb49691fddd2c51a0a515ea8a07c38889bb1bcb5 Mon Sep 17 00:00:00 2001 From: DigitallyRefined <129616584+DigitallyRefined@users.noreply.github.com> Date: Thu, 12 Mar 2026 14:59:33 +0000 Subject: [PATCH 5/7] Bump rc version --- CHANGELOG.md | 6 ++++++ cfspeedtest/version.py | 2 +- pyproject.toml | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c2e55f2..451f3b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to this project will be documented in this file. +# 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 diff --git a/cfspeedtest/version.py b/cfspeedtest/version.py index 4eea254..b39ae8b 100644 --- a/cfspeedtest/version.py +++ b/cfspeedtest/version.py @@ -1,3 +1,3 @@ """Current version of cfspeedtest.""" -__version__ = "2.1.3-rc" +__version__ = "2.1.4-rc" diff --git a/pyproject.toml b/pyproject.toml index 5ac3631..d091306 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "cloudflarepycli" -version = "2.1.3-rc" +version = "2.1.4-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" From daad56508d78b45fd4bf1eccf02564d74da5509e Mon Sep 17 00:00:00 2001 From: DigitallyRefined <129616584+DigitallyRefined@users.noreply.github.com> Date: Fri, 13 Mar 2026 15:51:10 +0000 Subject: [PATCH 6/7] fix: Return 0.0 and log a debug message when the Server-Timing header lacks a valid duration, instead of raising a ValueError. --- cfspeedtest/cloudflare.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cfspeedtest/cloudflare.py b/cfspeedtest/cloudflare.py index d9a8c28..b274b68 100644 --- a/cfspeedtest/cloudflare.py +++ b/cfspeedtest/cloudflare.py @@ -204,7 +204,11 @@ def _parse_server_timing(header_value: str) -> float: return float(clean_value) / 1e3 except (ValueError, TypeError): continue - raise ValueError("Server-Timing header did not include a valid duration") + 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.""" From 4e575e47a26de54f27147bf609d6a1d0831c8bb3 Mon Sep 17 00:00:00 2001 From: DigitallyRefined <129616584+DigitallyRefined@users.noreply.github.com> Date: Fri, 13 Mar 2026 16:34:54 +0000 Subject: [PATCH 7/7] Bump rc version --- CHANGELOG.md | 6 ++++++ cfspeedtest/version.py | 2 +- pyproject.toml | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 451f3b1..7bb8221 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ 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 diff --git a/cfspeedtest/version.py b/cfspeedtest/version.py index b39ae8b..d1a616c 100644 --- a/cfspeedtest/version.py +++ b/cfspeedtest/version.py @@ -1,3 +1,3 @@ """Current version of cfspeedtest.""" -__version__ = "2.1.4-rc" +__version__ = "2.1.5-rc" diff --git a/pyproject.toml b/pyproject.toml index d091306..ac2d313 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "cloudflarepycli" -version = "2.1.4-rc" +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"