diff --git a/CHANGELOG.md b/CHANGELOG.md index e2d771c..7bb8221 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/cfspeedtest/cloudflare.py b/cfspeedtest/cloudflare.py index 463001b..b274b68 100644 --- a/cfspeedtest/cloudflare.py +++ b/cfspeedtest/cloudflare.py @@ -14,6 +14,8 @@ import requests +from cfspeedtest.version import __version__ + log = logging.getLogger("cfspeedtest") @@ -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]: @@ -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"), @@ -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( @@ -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) @@ -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( diff --git a/cfspeedtest/version.py b/cfspeedtest/version.py index a2104ff..d1a616c 100644 --- a/cfspeedtest/version.py +++ b/cfspeedtest/version.py @@ -1,3 +1,3 @@ """Current version of cfspeedtest.""" -__version__ = "2.1.0" +__version__ = "2.1.5-rc" diff --git a/pyproject.toml b/pyproject.toml index f9d0f71..ac2d313 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"