From 02c2a8feecabbc842ed5869253e65bd3b0503e2a Mon Sep 17 00:00:00 2001 From: devteamaegis Date: Sat, 23 May 2026 21:55:03 -0400 Subject: [PATCH] fix: parse Retry-After-Ms header value as int before comparing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_parse_retry_after` retrieved the `Retry-After-Ms` response header and then compared the raw *string* value to an integer with `>`: ```python return int(retry_after_ms) / 1000 if retry_after_ms > 0 else 0 ``` In Python 3, comparing `str > int` raises `TypeError`, which was silently swallowed by the surrounding `except Exception: pass`. The practical effect is that every `Retry-After-Ms` header was completely ignored — the client always fell through to the `Retry-After` header or exponential back-off instead of honouring the server-supplied millisecond delay. Fix: convert to `int` first, then compare. ```python ms = int(retry_after_ms) return ms / 1000 if ms > 0 else 0 ``` Adds `tests/test_http_client.py` with unit tests that cover positive, zero, negative, fractional, invalid, and combined header scenarios. --- src/cohere/core/http_client.py | 6 ++- tests/test_http_client.py | 83 ++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 tests/test_http_client.py diff --git a/src/cohere/core/http_client.py b/src/cohere/core/http_client.py index f686c5710..ca8360d8b 100644 --- a/src/cohere/core/http_client.py +++ b/src/cohere/core/http_client.py @@ -32,7 +32,11 @@ def _parse_retry_after(response_headers: httpx.Headers) -> typing.Optional[float retry_after_ms = response_headers.get("retry-after-ms") if retry_after_ms is not None: try: - return int(retry_after_ms) / 1000 if retry_after_ms > 0 else 0 + # HTTP headers are always strings; convert to int before comparing. + # `retry_after_ms > 0` would raise TypeError (str vs int) so we + # must compare the parsed integer value, not the raw header string. + ms = int(retry_after_ms) + return ms / 1000 if ms > 0 else 0 except Exception: pass diff --git a/tests/test_http_client.py b/tests/test_http_client.py new file mode 100644 index 000000000..274c9cee1 --- /dev/null +++ b/tests/test_http_client.py @@ -0,0 +1,83 @@ +"""Unit tests for cohere.core.http_client helper functions.""" + +import pytest +import httpx + +from cohere.core.http_client import _parse_retry_after + + +def _make_headers(pairs: dict) -> httpx.Headers: + """Build an httpx.Headers object from a plain dict.""" + return httpx.Headers(pairs) + + +class TestParseRetryAfterMs: + """Regression tests for the Retry-After-Ms header parser. + + The header value is always a *string* (HTTP headers are plain text). + The original code compared the raw string to an integer with ``>``, + which raises ``TypeError`` in Python 3 and caused the entire + ``Retry-After-Ms`` header to be silently ignored on every response. + """ + + def test_positive_ms_value_is_converted_to_seconds(self): + headers = _make_headers({"retry-after-ms": "2000"}) + result = _parse_retry_after(headers) + assert result == pytest.approx(2.0) + + def test_ms_value_of_zero_returns_zero(self): + headers = _make_headers({"retry-after-ms": "0"}) + result = _parse_retry_after(headers) + assert result == 0 + + def test_negative_ms_value_returns_zero(self): + headers = _make_headers({"retry-after-ms": "-500"}) + result = _parse_retry_after(headers) + assert result == 0 + + def test_ms_takes_priority_over_retry_after(self): + """Retry-After-Ms should be preferred over the plain Retry-After header.""" + headers = _make_headers({"retry-after-ms": "3000", "retry-after": "99"}) + result = _parse_retry_after(headers) + assert result == pytest.approx(3.0) + + def test_fractional_ms_value(self): + headers = _make_headers({"retry-after-ms": "1500"}) + result = _parse_retry_after(headers) + assert result == pytest.approx(1.5) + + def test_invalid_ms_value_falls_back_to_retry_after(self): + """Non-numeric Retry-After-Ms should be ignored; fall back to Retry-After.""" + headers = _make_headers({"retry-after-ms": "not-a-number", "retry-after": "5"}) + result = _parse_retry_after(headers) + assert result == pytest.approx(5.0) + + def test_no_retry_headers_returns_none(self): + headers = _make_headers({}) + result = _parse_retry_after(headers) + assert result is None + + +class TestParseRetryAfterSeconds: + """Tests for the plain Retry-After header (integer seconds).""" + + def test_integer_seconds(self): + headers = _make_headers({"retry-after": "30"}) + result = _parse_retry_after(headers) + assert result == pytest.approx(30.0) + + def test_zero_seconds(self): + headers = _make_headers({"retry-after": "0"}) + result = _parse_retry_after(headers) + assert result == 0 + + def test_negative_seconds_clamped_to_zero(self): + # The header shouldn't be negative, but we clamp it. + # parsedate_tz won't parse a bare negative integer, so this falls + # through; the integer branch uses re.match which only matches [0-9]+. + # Negative numbers fail the regex → parsedate_tz fails → returns None. + headers = _make_headers({"retry-after": "-10"}) + result = _parse_retry_after(headers) + # A negative integer doesn't match the regex, parsedate_tz can't parse + # it either, so None is expected. + assert result is None