From 5fccd720c4f1eee8c5c8b33cb1af124104c239c2 Mon Sep 17 00:00:00 2001 From: KT Date: Tue, 11 Aug 2026 22:16:11 +0800 Subject: [PATCH 1/3] fix(*): resolve the latest release without the github api quota Version discovery hit only api.github.com, whose unauthenticated bucket is 60 requests per hour keyed on the source IP, so a shared egress exhausts it for everyone behind that address and both `raven upgrade` and the one-line installers stop resolving a version. The release page carries no API quota, so its redirect now backs the API up: it names the latest stable tag, and the wheel URL is derived from the same shape the payload validator already enforces. Only transport and status failures fall back. A payload that parsed as draft or prerelease is never routed around, because the release page cannot re-check those flags. Report the failure honestly too. A spent quota reported "Check your network and try again", a sentence appended unconditionally to every error, down to "Editable Raven installations cannot be upgraded". Release-lookup failures now raise ReleaseLookupError and report the quota facts with the reset time, the network advice appears only when both paths failed at the transport layer, and the installer hint stays on local-installation errors. Co-authored-by: Claude (claude-opus-5[1m]) --- install.ps1 | 43 ++++++- install.sh | 24 +++- raven/cli/upgrade_commands.py | 124 ++++++++++++++++---- tests/test_cli_upgrade_commands.py | 175 +++++++++++++++++++++++++++-- 4 files changed, 329 insertions(+), 37 deletions(-) diff --git a/install.ps1 b/install.ps1 index b54b5deb..9b849093 100644 --- a/install.ps1 +++ b/install.ps1 @@ -191,15 +191,48 @@ function Ensure-Node { } } +# Reads the latest stable tag off the release page redirect. The GitHub API caps +# unauthenticated callers at 60 requests/hour per IP, which a shared egress can +# exhaust; the release page carries no API quota. Returns "" when the redirect is +# missing or does not name a stable tag, so the caller can fail with its own message. +function Resolve-RavenLatestVersion { + $target = "" + try { + $response = Invoke-WebRequest "https://github.com/EverMind-AI/Raven/releases/latest" -MaximumRedirection 0 -UseBasicParsing -ErrorAction Stop + $target = [string]$response.Headers.Location + } catch { + # Windows PowerShell raises on an unfollowed redirect; the Location header + # still rides on the exception's response. + $failed = $_.Exception.Response + if ($failed) { + try { $target = [string]$failed.Headers.Location } catch { $target = "" } + if (-not $target) { + try { $target = [string]$failed.Headers.GetValues("Location")[0] } catch { $target = "" } + } + } + } + if ($target -match "^https://github\.com/EverMind-AI/Raven/releases/tag/v([0-9]+\.[0-9]+\.[0-9]+)$") { + return $Matches[1] + } + return "" +} + function Resolve-RavenWheel { if ($env:RAVEN_WHEEL_URL) { return $env:RAVEN_WHEEL_URL } Write-Info "Resolving the latest Raven release from GitHub..." - $release = Invoke-RestMethod "https://api.github.com/repos/EverMind-AI/Raven/releases/latest" -Headers @{ "User-Agent" = "raven-installer" } - $asset = $release.assets | Where-Object { $_.browser_download_url -match "/raven-[^/]+\.whl$" } | Select-Object -First 1 - if (-not $asset) { - Fail "Could not resolve the latest Raven release wheel from GitHub. Set RAVEN_WHEEL_URL to a wheel URL." + try { + $release = Invoke-RestMethod "https://api.github.com/repos/EverMind-AI/Raven/releases/latest" -Headers @{ "User-Agent" = "raven-installer" } + $asset = $release.assets | Where-Object { $_.browser_download_url -match "/raven-[^/]+\.whl$" } | Select-Object -First 1 + if ($asset) { return $asset.browser_download_url } + Write-Warn "GitHub API returned no release wheel; falling back to the release page." + } catch { + Write-Warn "GitHub API lookup failed ($($_.Exception.Message)); falling back to the release page." + } + $version = Resolve-RavenLatestVersion + if (-not $version) { + Fail "Could not resolve the latest Raven release wheel from GitHub. Retry later, or set RAVEN_WHEEL_URL to a wheel URL." } - return $asset.browser_download_url + return "https://github.com/EverMind-AI/Raven/releases/download/v$version/raven-$version-py3-none-any.whl" } function Resolve-RavenConstraints([string]$WheelUrl) { diff --git a/install.sh b/install.sh index 048ec4b0..f41d2249 100755 --- a/install.sh +++ b/install.sh @@ -189,10 +189,30 @@ install_raven() { wheel_url="${RAVEN_WHEEL_URL:-}" if [ -z "$wheel_url" ]; then info "Resolving the latest raven release from GitHub..." - wheel_url="$(curl -fsSL "https://api.github.com/repos/EverMind-AI/raven/releases/latest" 2>/dev/null \ + wheel_url="$(curl -fsSL "https://api.github.com/repos/EverMind-AI/Raven/releases/latest" 2>/dev/null \ | grep -oE 'https://[^"]*/raven-[^"]*\.whl' | head -n1)" fi - [ -n "$wheel_url" ] || die "Could not resolve the latest raven release wheel from GitHub (check network, or set RAVEN_WHEEL_URL to a wheel URL)." + if [ -z "$wheel_url" ]; then + # The GitHub API caps unauthenticated callers at 60 requests/hour per IP, so a + # shared egress can exhaust it. The release page carries no API quota: its + # redirect names the latest stable tag, and the wheel URL is derived from it. + warn "GitHub API returned no release wheel; falling back to the release page." + tag="$(curl -fsS -o /dev/null -w '%{redirect_url}' \ + "https://github.com/EverMind-AI/Raven/releases/latest")" || tag="" + version="${tag##*/}" + version="${version#v}" + case "$version" in + *.*.*) ;; + *) version="" ;; + esac + case "$version" in + *[!0-9.]*) version="" ;; + esac + if [ -n "$version" ]; then + wheel_url="https://github.com/EverMind-AI/Raven/releases/download/v${version}/raven-${version}-py3-none-any.whl" + fi + fi + [ -n "$wheel_url" ] || die "Could not resolve the latest raven release wheel from GitHub. Retry later, or set RAVEN_WHEEL_URL to a wheel URL." # Derive the locked-constraints URL from the wheel URL (same release dir) so # the constraints always match the wheel being installed, including when # RAVEN_WHEEL_URL pins an older wheel. Missing asset / download failure -> diff --git a/raven/cli/upgrade_commands.py b/raven/cli/upgrade_commands.py index c6fe34e2..ec2aefbd 100644 --- a/raven/cli/upgrade_commands.py +++ b/raven/cli/upgrade_commands.py @@ -9,6 +9,7 @@ import sys import tomllib from dataclasses import dataclass +from datetime import datetime from importlib import metadata from pathlib import Path from urllib.parse import urlparse @@ -18,6 +19,9 @@ from rich.console import Console LATEST_RELEASE_API = "https://api.github.com/repos/EverMind-AI/Raven/releases/latest" +LATEST_RELEASE_WEB = "https://github.com/EverMind-AI/Raven/releases/latest" +RELEASE_TAG_PREFIX = "https://github.com/EverMind-AI/Raven/releases/tag/" +RELEASE_DOWNLOAD_PREFIX = "https://github.com/EverMind-AI/Raven/releases/download/" _VERSION_RE = re.compile(r"^v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$") console = Console() @@ -26,6 +30,11 @@ class UpgradeError(RuntimeError): pass +class ReleaseLookupError(UpgradeError): + """Latest-release discovery failed. The local installation is fine, so the + caller must not advise reinstalling.""" + + @dataclass(frozen=True) class ReleaseInfo: version: str @@ -187,64 +196,131 @@ def _current_version() -> str: return metadata.version("raven") +def _release_wheel_name(version: str) -> str: + return f"raven-{version}-py3-none-any.whl" + + +def _release_wheel_url(version: str) -> str: + return f"{RELEASE_DOWNLOAD_PREFIX}v{version}/{_release_wheel_name(version)}" + + def _parse_release_payload(payload: object) -> ReleaseInfo: if not isinstance(payload, dict): - raise UpgradeError("Malformed GitHub release payload") + raise ReleaseLookupError("Malformed GitHub release payload") draft = payload.get("draft") prerelease = payload.get("prerelease") if not isinstance(draft, bool) or not isinstance(prerelease, bool): - raise UpgradeError("Malformed GitHub release payload") + raise ReleaseLookupError("Malformed GitHub release payload") if draft or prerelease: - raise UpgradeError("Latest Raven release is not stable") + raise ReleaseLookupError("Latest Raven release is not stable") tag_name = payload.get("tag_name") if not isinstance(tag_name, str) or not tag_name.startswith("v"): - raise UpgradeError("Malformed GitHub release payload") + raise ReleaseLookupError("Malformed GitHub release payload") version = ".".join(str(part) for part in _version_key(tag_name)) assets = payload.get("assets") if not isinstance(assets, list): - raise UpgradeError("Malformed GitHub release payload") + raise ReleaseLookupError("Malformed GitHub release payload") - wheel_name = f"raven-{version}-py3-none-any.whl" + wheel_name = _release_wheel_name(version) exact_wheels: list[str] = [] for asset in assets: if not isinstance(asset, dict): - raise UpgradeError("Malformed GitHub release payload") + raise ReleaseLookupError("Malformed GitHub release payload") name = asset.get("name") wheel_url = asset.get("browser_download_url") if not isinstance(name, str) or not isinstance(wheel_url, str): - raise UpgradeError("Malformed GitHub release payload") + raise ReleaseLookupError("Malformed GitHub release payload") if name == wheel_name: exact_wheels.append(wheel_url) if len(exact_wheels) != 1: - raise UpgradeError(f"Expected exactly one release wheel named {wheel_name}") + raise ReleaseLookupError(f"Expected exactly one release wheel named {wheel_name}") wheel_url = exact_wheels[0] - parsed_url = urlparse(wheel_url) - expected_path = f"/EverMind-AI/Raven/releases/download/v{version}/{wheel_name}" - if parsed_url.scheme != "https" or parsed_url.netloc != "github.com" or parsed_url.path != expected_path: - raise UpgradeError(f"Untrusted Raven release wheel URL: {wheel_url}") + if wheel_url != _release_wheel_url(version): + raise ReleaseLookupError(f"Untrusted Raven release wheel URL: {wheel_url}") return ReleaseInfo(version=version, wheel_url=wheel_url) -def _fetch_latest_release(client: httpx.Client | None = None) -> ReleaseInfo: +def _rate_limit_detail(response: httpx.Response) -> str: + detail = "GitHub rate limit exhausted (unauthenticated requests share 60 per hour per IP)" + try: + resets_at = datetime.fromtimestamp(int(response.headers["x-ratelimit-reset"])) + except (KeyError, ValueError, OSError, OverflowError): + return detail + return f"{detail}, resetting at {resets_at:%H:%M:%S}" + + +def _github_failure_detail(error: Exception) -> str: + if isinstance(error, httpx.HTTPStatusError): + response = error.response + if response.status_code in (403, 429) and response.headers.get("x-ratelimit-remaining") == "0": + return _rate_limit_detail(response) + return f"HTTP {response.status_code} {response.reason_phrase}".strip() + return str(error).strip() or type(error).__name__ + + +def _sentence(error: Exception) -> str: + """Terminate the message with exactly one period; some already carry theirs.""" + return f"{str(error).rstrip('.')}." + + +def _fetch_latest_release_via_api(client: httpx.Client) -> ReleaseInfo: headers = { "Accept": "application/vnd.github+json", "User-Agent": f"raven/{_current_version()}", "X-GitHub-Api-Version": "2022-11-28", } + response = client.get(LATEST_RELEASE_API, headers=headers) + response.raise_for_status() + return _parse_release_payload(response.json()) + + +def _fetch_latest_release_via_redirect(client: httpx.Client) -> ReleaseInfo: + """Resolve the latest release from the release page, which no API quota applies to.""" + headers = {"User-Agent": f"raven/{_current_version()}"} + response = client.get(LATEST_RELEASE_WEB, headers=headers, follow_redirects=False) + location = response.headers.get("location", "") + if not response.has_redirect_location or not location.startswith(RELEASE_TAG_PREFIX): + raise ReleaseLookupError(f"HTTP {response.status_code} without a release tag redirect") + + version = ".".join(str(part) for part in _version_key(location[len(RELEASE_TAG_PREFIX) :])) + wheel_url = _release_wheel_url(version) + client.head(wheel_url, follow_redirects=True).raise_for_status() + return ReleaseInfo(version=version, wheel_url=wheel_url) + + +def _resolve_latest_release(client: httpx.Client) -> ReleaseInfo: + try: + return _fetch_latest_release_via_api(client) + except httpx.HTTPError as error: + # Only transport / status failures fall back. A payload that parsed as draft + # or prerelease must not be routed around: the release page cannot re-check + # those flags, so falling back there would install what the API rejected. + api_error = error + + try: + return _fetch_latest_release_via_redirect(client) + except (UpgradeError, httpx.HTTPError) as web_error: + message = ( + f"could not resolve the latest Raven release " + f"(GitHub API: {_github_failure_detail(api_error)}; " + f"release page: {_github_failure_detail(web_error)})" + ) + if isinstance(api_error, httpx.TransportError) and isinstance(web_error, httpx.TransportError): + message += "; check your network and try again" + raise ReleaseLookupError(message) from web_error + + +def _fetch_latest_release(client: httpx.Client | None = None) -> ReleaseInfo: if client is not None: - response = client.get(LATEST_RELEASE_API, headers=headers) - response.raise_for_status() - return _parse_release_payload(response.json()) + return _resolve_latest_release(client) with httpx.Client(timeout=10.0, follow_redirects=True) as owned_client: - response = owned_client.get(LATEST_RELEASE_API, headers=headers) - response.raise_for_status() - return _parse_release_payload(response.json()) + return _resolve_latest_release(owned_client) def _direct_url_data() -> dict[str, object] | None: @@ -450,6 +526,9 @@ def upgrade( ) _handoff_upgrade(release, current_version, target) + except ReleaseLookupError as exc: + console.print(f"[red]Unable to upgrade Raven:[/red] {_sentence(exc)}") + raise typer.Exit(1) from exc except ( UpgradeError, httpx.HTTPError, @@ -457,8 +536,7 @@ def upgrade( metadata.PackageNotFoundError, ) as exc: console.print( - f"[red]Unable to upgrade Raven:[/red] {exc}. " - "Check your network and try again; if the problem persists, " - "rerun the official installer." + f"[red]Unable to upgrade Raven:[/red] {_sentence(exc)} " + "If the problem persists, rerun the official installer." ) raise typer.Exit(1) from exc diff --git a/tests/test_cli_upgrade_commands.py b/tests/test_cli_upgrade_commands.py index 452ddd5d..3087883d 100644 --- a/tests/test_cli_upgrade_commands.py +++ b/tests/test_cli_upgrade_commands.py @@ -6,7 +6,9 @@ import subprocess import sys import tomllib +from collections.abc import Callable from dataclasses import FrozenInstanceError +from datetime import datetime from pathlib import Path from unittest.mock import Mock @@ -19,6 +21,7 @@ WHEEL_NAME = "raven-0.1.4-py3-none-any.whl" WHEEL_URL = "https://github.com/EverMind-AI/Raven/releases/download/v0.1.4/raven-0.1.4-py3-none-any.whl" +RATE_LIMIT_RESET = 1786451027 MALFORMED_DIRECT_URL_METADATA = [ pytest.param("", id="empty-document"), pytest.param("[]", id="top-level-list"), @@ -61,6 +64,30 @@ def _release_payload(**overrides: object) -> dict[str, object]: return payload +def _quota_exhausted_response() -> httpx.Response: + return httpx.Response( + 403, + headers={ + "x-ratelimit-limit": "60", + "x-ratelimit-remaining": "0", + "x-ratelimit-reset": str(RATE_LIMIT_RESET), + }, + json={"message": "API rate limit exceeded for 203.0.113.7."}, + ) + + +def _quota_spent_handler(requested: list[str]) -> Callable[[httpx.Request], httpx.Response]: + def handler(request: httpx.Request) -> httpx.Response: + requested.append(f"{request.method} {request.url}") + if str(request.url) == upgrade_commands.LATEST_RELEASE_API: + return _quota_exhausted_response() + if str(request.url) == upgrade_commands.LATEST_RELEASE_WEB: + return httpx.Response(302, headers={"location": f"{upgrade_commands.RELEASE_TAG_PREFIX}v0.1.4"}) + return httpx.Response(200) + + return handler + + def test_release_info_is_immutable() -> None: release = upgrade_commands.ReleaseInfo(version="0.1.4", wheel_url=WHEEL_URL) @@ -112,7 +139,9 @@ def test_parse_release_payload_selects_exact_release_wheel() -> None: @pytest.mark.parametrize("field", ["draft", "prerelease"]) def test_parse_release_payload_rejects_unstable_releases(field: str) -> None: - with pytest.raises(upgrade_commands.UpgradeError): + # ReleaseLookupError, not a bare UpgradeError: a remote release problem must not + # make the CLI advise reinstalling a healthy local installation. + with pytest.raises(upgrade_commands.ReleaseLookupError): upgrade_commands._parse_release_payload(_release_payload(**{field: True})) @@ -196,23 +225,127 @@ def handler(request: httpx.Request) -> httpx.Response: assert release == upgrade_commands.ReleaseInfo(version="0.1.4", wheel_url=WHEEL_URL) -def test_fetch_latest_release_propagates_timeout() -> None: +def test_fetch_latest_release_reports_both_paths_on_transport_failure() -> None: def handler(request: httpx.Request) -> httpx.Response: raise httpx.ReadTimeout("timed out", request=request) with httpx.Client(transport=httpx.MockTransport(handler)) as client: - with pytest.raises(httpx.ReadTimeout): + with pytest.raises(upgrade_commands.ReleaseLookupError) as excinfo: upgrade_commands._fetch_latest_release(client) + message = str(excinfo.value) + assert "GitHub API: timed out" in message + assert "release page: timed out" in message + assert "check your network" in message -def test_fetch_latest_release_propagates_non_2xx_response() -> None: + +def test_fetch_latest_release_reports_both_paths_on_server_error() -> None: def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(503, json={"message": "unavailable"}) with httpx.Client(transport=httpx.MockTransport(handler)) as client: - with pytest.raises(httpx.HTTPStatusError): + with pytest.raises(upgrade_commands.ReleaseLookupError) as excinfo: + upgrade_commands._fetch_latest_release(client) + + message = str(excinfo.value) + assert "GitHub API: HTTP 503" in message + assert "release page: HTTP 503" in message + assert "check your network" not in message + + +def test_fetch_latest_release_falls_back_to_release_page_when_quota_is_spent( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(upgrade_commands, "_current_version", lambda: "0.1.3") + requested: list[str] = [] + + with httpx.Client(transport=httpx.MockTransport(_quota_spent_handler(requested))) as client: + release = upgrade_commands._fetch_latest_release(client) + + assert release == upgrade_commands.ReleaseInfo(version="0.1.4", wheel_url=WHEEL_URL) + assert requested == [ + f"GET {upgrade_commands.LATEST_RELEASE_API}", + f"GET {upgrade_commands.LATEST_RELEASE_WEB}", + f"HEAD {WHEEL_URL}", + ] + + +def test_fetch_latest_release_reports_spent_quota_reset_time(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(upgrade_commands, "_current_version", lambda: "0.1.3") + + def handler(request: httpx.Request) -> httpx.Response: + if str(request.url) == upgrade_commands.LATEST_RELEASE_API: + return _quota_exhausted_response() + return httpx.Response(503) + + with httpx.Client(transport=httpx.MockTransport(handler)) as client: + with pytest.raises(upgrade_commands.ReleaseLookupError) as excinfo: upgrade_commands._fetch_latest_release(client) + message = str(excinfo.value) + assert "rate limit exhausted" in message + assert "60 per hour per IP" in message + assert datetime.fromtimestamp(RATE_LIMIT_RESET).strftime("%H:%M:%S") in message + assert "check your network" not in message + + +def test_fetch_latest_release_rejects_prerelease_tag_from_release_page( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(upgrade_commands, "_current_version", lambda: "0.1.3") + requested: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requested.append(f"{request.method} {request.url}") + if str(request.url) == upgrade_commands.LATEST_RELEASE_API: + return _quota_exhausted_response() + return httpx.Response(302, headers={"location": f"{upgrade_commands.RELEASE_TAG_PREFIX}v0.1.5-rc1"}) + + with httpx.Client(transport=httpx.MockTransport(handler)) as client: + with pytest.raises(upgrade_commands.ReleaseLookupError) as excinfo: + upgrade_commands._fetch_latest_release(client) + + assert "Unsupported Raven version: v0.1.5-rc1" in str(excinfo.value) + assert not any(entry.startswith("HEAD") for entry in requested) + + +def test_fetch_latest_release_rejects_release_page_without_a_wheel(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(upgrade_commands, "_current_version", lambda: "0.1.3") + + def handler(request: httpx.Request) -> httpx.Response: + if str(request.url) == upgrade_commands.LATEST_RELEASE_API: + return _quota_exhausted_response() + if str(request.url) == upgrade_commands.LATEST_RELEASE_WEB: + return httpx.Response(302, headers={"location": f"{upgrade_commands.RELEASE_TAG_PREFIX}v0.1.4"}) + return httpx.Response(404) + + with httpx.Client(transport=httpx.MockTransport(handler)) as client: + with pytest.raises(upgrade_commands.ReleaseLookupError) as excinfo: + upgrade_commands._fetch_latest_release(client) + + assert "release page: HTTP 404" in str(excinfo.value) + + +def test_fetch_latest_release_does_not_route_around_a_prerelease_payload( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(upgrade_commands, "_current_version", lambda: "0.1.3") + requested: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requested.append(str(request.url)) + if str(request.url) == upgrade_commands.LATEST_RELEASE_API: + return httpx.Response(200, json=_release_payload(prerelease=True)) + return httpx.Response(302, headers={"location": f"{upgrade_commands.RELEASE_TAG_PREFIX}v0.1.4"}) + + with httpx.Client(transport=httpx.MockTransport(handler)) as client: + with pytest.raises(upgrade_commands.UpgradeError) as excinfo: + upgrade_commands._fetch_latest_release(client) + + assert "not stable" in str(excinfo.value) + assert "release page" not in str(excinfo.value) + assert requested == [upgrade_commands.LATEST_RELEASE_API] + def test_fetch_latest_release_propagates_invalid_json() -> None: def handler(request: httpx.Request) -> httpx.Response: @@ -868,9 +1001,11 @@ def test_upgrade_refuses_editable_install(monkeypatch: pytest.MonkeyPatch) -> No result = runner.invoke(app, ["upgrade"]) + output = " ".join(result.stdout.split()) assert result.exit_code == 1 assert "editable" in result.stdout.lower() assert "source checkout" in result.stdout.lower() + assert "Raven.." not in output target_lookup.assert_not_called() handoff.assert_not_called() @@ -975,11 +1110,38 @@ def fetch() -> upgrade_commands.ReleaseInfo: output = " ".join(result.stdout.lower().split()) assert result.exit_code == 1 assert "Unable to upgrade Raven" in result.stdout - assert "try again" in output assert "official installer" in output assert "Traceback" not in result.stdout +def test_upgrade_reports_a_spent_quota_without_network_or_installer_advice( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(upgrade_commands, "_current_version", lambda: "0.1.3") + + def handler(request: httpx.Request) -> httpx.Response: + if str(request.url) == upgrade_commands.LATEST_RELEASE_API: + return _quota_exhausted_response() + return httpx.Response(503) + + def fetch() -> upgrade_commands.ReleaseInfo: + with httpx.Client(transport=httpx.MockTransport(handler)) as client: + return upgrade_commands._resolve_latest_release(client) + + monkeypatch.setattr(upgrade_commands, "_fetch_latest_release", fetch) + + result = runner.invoke(app, ["upgrade"]) + + output = " ".join(result.stdout.split()) + assert result.exit_code == 1 + assert "rate limit exhausted" in output + assert "60 per hour per IP" in output + assert datetime.fromtimestamp(RATE_LIMIT_RESET).strftime("%H:%M:%S") in output + assert "network" not in output.lower() + assert "official installer" not in output.lower() + assert "Traceback" not in result.stdout + + @pytest.mark.parametrize("guard", ["editable", "receipt"]) def test_upgrade_reports_malformed_installation_metadata( monkeypatch: pytest.MonkeyPatch, @@ -1007,6 +1169,5 @@ def malformed_receipt() -> upgrade_commands.ToolInstallTarget | None: output = " ".join(result.stdout.lower().split()) assert result.exit_code == 1 assert "Unable to upgrade Raven" in result.stdout - assert "try again" in output assert "official installer" in output assert "Traceback" not in result.stdout From 0818bcfd492c7cfa945fa1b00368449f6b9f4ec1 Mon Sep 17 00:00:00 2001 From: KT Date: Wed, 12 Aug 2026 14:34:20 +0800 Subject: [PATCH 2/3] fix(*): keep the update notice off the github api quota Review on #299 found that the daily update check is what drains the bucket this branch was making survivable: update_notice._refresh() called the API-first _fetch_latest_release(), so every install behind a shared egress spent one unauthenticated request per day, on top of every upgrade. The notice needs a version string and nothing else, so it now calls fetch_latest_version(), which reads the release page redirect and sends neither the API request nor the wheel HEAD. Nine smaller findings from the same review: - a 200 with a non-JSON body raises httpx.DecodingError, so a proxy or captive portal reaches the fallback instead of surfacing a raw parser message that also advised reinstalling a healthy installation - a missing wheel is reported against the wheel rather than against the release page that answered correctly, and the HEAD now carries the raven User-Agent like the other two requests - install.sh validates the redirect target and accepts exactly three numeric fields with no leading zeros, matching the CLI and install.ps1; the previous pattern accepted `..`, `1.2.3.4` and `0.01.2` - the secondary rate limit reports its retry-after instead of a bare status line - the fallback requests use a 5s timeout, so adding a third request does not triple the worst-case wait - _sentence strips one trailing period instead of the whole run, keeping an ellipsis intact, and _version_key quotes the value it rejected - the fallback comment now states the rule the code implements: no payload-level failure is routed around, not only draft or prerelease Co-authored-by: Claude (claude-opus-5[1m]) --- install.sh | 20 ++++- raven/cli/update_notice.py | 21 ++--- raven/cli/upgrade_commands.py | 98 ++++++++++++++++++----- tests/test_cli_update_notice.py | 26 +++++-- tests/test_cli_upgrade_commands.py | 120 ++++++++++++++++++++++++++--- 5 files changed, 235 insertions(+), 50 deletions(-) diff --git a/install.sh b/install.sh index f41d2249..f9d5543b 100755 --- a/install.sh +++ b/install.sh @@ -199,15 +199,27 @@ install_raven() { warn "GitHub API returned no release wheel; falling back to the release page." tag="$(curl -fsS -o /dev/null -w '%{redirect_url}' \ "https://github.com/EverMind-AI/Raven/releases/latest")" || tag="" - version="${tag##*/}" + # Same shape the CLI and install.ps1 enforce: the redirect must land on this + # repository's tag page, and the version must be exactly three numeric fields + # with no leading zeros. + case "$tag" in + https://github.com/EverMind-AI/Raven/releases/tag/v*) version="${tag##*/}" ;; + *) version="" ;; + esac version="${version#v}" case "$version" in + *.*.*.*) version="" ;; *.*.*) ;; *) version="" ;; esac - case "$version" in - *[!0-9.]*) version="" ;; - esac + if [ -n "$version" ]; then + v_rest="${version#*.}" + for field in "${version%%.*}" "${v_rest%%.*}" "${v_rest#*.}"; do + case "$field" in + ""|*[!0-9]*|0[0-9]*) version="" ;; + esac + done + fi if [ -n "$version" ]; then wheel_url="https://github.com/EverMind-AI/Raven/releases/download/v${version}/raven-${version}-py3-none-any.whl" fi diff --git a/raven/cli/update_notice.py b/raven/cli/update_notice.py index 7f72c6cd..12bea5c5 100644 --- a/raven/cli/update_notice.py +++ b/raven/cli/update_notice.py @@ -5,13 +5,15 @@ ``update_command`` (see ``ui-tui/src/components/appChrome.tsx``); this module is what fills those in. -The live check hits the GitHub releases API, which is too slow to run on the -session-create hot path, so we keep a small cache in the runtime cache dir and -refresh it in a daemon thread at most once a day. A launch therefore shows the -notice based on the *cached* latest version; the first launch after a release -lands refreshes the cache and the notice appears on the next launch. Any -network or parse failure is swallowed -- an update nudge must never break -startup. +The live check reads the release page redirect (not the releases API, whose +unauthenticated quota is 60 requests per hour per IP -- a daily check from every +install behind one egress is enough to drain it, and a nudge must not cost the +budget that ``raven upgrade`` needs). It is still too slow for the session-create +hot path, so we keep a small cache in the runtime cache dir and refresh it in a +daemon thread at most once a day. A launch therefore shows the notice based on the +*cached* latest version; the first launch after a release lands refreshes the cache +and the notice appears on the next launch. Any network or parse failure is +swallowed -- an update nudge must never break startup. Set ``RAVEN_NO_UPDATE_CHECK=1`` to opt out of both the fetch and the hint. """ @@ -111,10 +113,9 @@ def _refresh() -> None: keep = previous if isinstance(previous, str) else None try: - from raven.cli.upgrade_commands import _fetch_latest_release + from raven.cli.upgrade_commands import fetch_latest_version - release = _fetch_latest_release() - _write_cache(release.version, now=time.time()) + _write_cache(fetch_latest_version(), now=time.time()) except Exception: # Offline, rate-limited, or the latest release is a draft/prerelease. # Stamp checked_at anyway so we back off for a full TTL instead of diff --git a/raven/cli/upgrade_commands.py b/raven/cli/upgrade_commands.py index ec2aefbd..ffab9ff2 100644 --- a/raven/cli/upgrade_commands.py +++ b/raven/cli/upgrade_commands.py @@ -23,6 +23,10 @@ RELEASE_TAG_PREFIX = "https://github.com/EverMind-AI/Raven/releases/tag/" RELEASE_DOWNLOAD_PREFIX = "https://github.com/EverMind-AI/Raven/releases/download/" _VERSION_RE = re.compile(r"^v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$") +_REQUEST_TIMEOUT = 10.0 +# The fallback exists to rescue a failing command, so it may not add another full +# timeout to the wait: three sequential requests at 10s would triple the worst case. +_FALLBACK_TIMEOUT = 5.0 console = Console() @@ -187,7 +191,7 @@ def _upgrade_helper_bootstrap() -> str: def _version_key(value: str) -> tuple[int, int, int]: match = _VERSION_RE.fullmatch(value) if match is None: - raise UpgradeError(f"Unsupported Raven version: {value}") + raise UpgradeError(f"Unsupported Raven version: {value!r}") major, minor, patch = match.groups() return int(major), int(minor), int(patch) @@ -196,6 +200,10 @@ def _current_version() -> str: return metadata.version("raven") +def _user_agent() -> str: + return f"raven/{_current_version()}" + + def _release_wheel_name(version: str) -> str: return f"raven-{version}-py3-none-any.whl" @@ -258,39 +266,74 @@ def _rate_limit_detail(response: httpx.Response) -> str: def _github_failure_detail(error: Exception) -> str: if isinstance(error, httpx.HTTPStatusError): response = error.response - if response.status_code in (403, 429) and response.headers.get("x-ratelimit-remaining") == "0": - return _rate_limit_detail(response) + if response.status_code in (403, 429): + if response.headers.get("x-ratelimit-remaining") == "0": + return _rate_limit_detail(response) + # The secondary (abuse) limit leaves the primary budget untouched and + # says when to come back instead. + retry_after = response.headers.get("retry-after") + if retry_after: + return f"GitHub asked us to retry in {retry_after}s (HTTP {response.status_code})" return f"HTTP {response.status_code} {response.reason_phrase}".strip() return str(error).strip() or type(error).__name__ def _sentence(error: Exception) -> str: - """Terminate the message with exactly one period; some already carry theirs.""" - return f"{str(error).rstrip('.')}." + """Terminate the message with a period unless it already ends in one. + + Strips a single trailing period rather than the whole run, so a message ending in + an ellipsis keeps it. + """ + return f"{str(error).removesuffix('.')}." def _fetch_latest_release_via_api(client: httpx.Client) -> ReleaseInfo: headers = { "Accept": "application/vnd.github+json", - "User-Agent": f"raven/{_current_version()}", + "User-Agent": _user_agent(), "X-GitHub-Api-Version": "2022-11-28", } response = client.get(LATEST_RELEASE_API, headers=headers) response.raise_for_status() - return _parse_release_payload(response.json()) - - -def _fetch_latest_release_via_redirect(client: httpx.Client) -> ReleaseInfo: - """Resolve the latest release from the release page, which no API quota applies to.""" - headers = {"User-Agent": f"raven/{_current_version()}"} - response = client.get(LATEST_RELEASE_WEB, headers=headers, follow_redirects=False) + try: + payload = response.json() + except ValueError as exc: + # A proxy or captive portal answering 200 with HTML is a remote failure the + # release page can recover from, so it has to reach the fallback: DecodingError + # is an httpx.HTTPError but not a TransportError, so it never reads as "offline". + raise httpx.DecodingError("GitHub API returned a non-JSON body", request=response.request) from exc + return _parse_release_payload(payload) + + +def _fetch_latest_version_via_redirect(client: httpx.Client) -> str: + """Read the latest stable version off the release page, which no API quota applies to.""" + response = client.get( + LATEST_RELEASE_WEB, + headers={"User-Agent": _user_agent()}, + follow_redirects=False, + timeout=_FALLBACK_TIMEOUT, + ) location = response.headers.get("location", "") - if not response.has_redirect_location or not location.startswith(RELEASE_TAG_PREFIX): + tag = location[len(RELEASE_TAG_PREFIX) :] if location.startswith(RELEASE_TAG_PREFIX) else "" + if not response.has_redirect_location or not tag: raise ReleaseLookupError(f"HTTP {response.status_code} without a release tag redirect") + return ".".join(str(part) for part in _version_key(tag)) - version = ".".join(str(part) for part in _version_key(location[len(RELEASE_TAG_PREFIX) :])) + +def _fetch_latest_release_via_redirect(client: httpx.Client) -> ReleaseInfo: + version = _fetch_latest_version_via_redirect(client) wheel_url = _release_wheel_url(version) - client.head(wheel_url, follow_redirects=True).raise_for_status() + try: + client.head( + wheel_url, + headers={"User-Agent": _user_agent()}, + follow_redirects=True, + timeout=_FALLBACK_TIMEOUT, + ).raise_for_status() + except httpx.HTTPError as exc: + raise ReleaseLookupError( + f"release {version} has no wheel at the expected URL ({_github_failure_detail(exc)})" + ) from exc return ReleaseInfo(version=version, wheel_url=wheel_url) @@ -298,9 +341,10 @@ def _resolve_latest_release(client: httpx.Client) -> ReleaseInfo: try: return _fetch_latest_release_via_api(client) except httpx.HTTPError as error: - # Only transport / status failures fall back. A payload that parsed as draft - # or prerelease must not be routed around: the release page cannot re-check - # those flags, so falling back there would install what the API rejected. + # Only transport / status / decoding failures fall back. No payload-level + # failure is routed around, because the release page cannot re-check what the + # payload carries -- above all the draft / prerelease flags, where falling + # back would install exactly what the API rejected. api_error = error try: @@ -319,10 +363,24 @@ def _resolve_latest_release(client: httpx.Client) -> ReleaseInfo: def _fetch_latest_release(client: httpx.Client | None = None) -> ReleaseInfo: if client is not None: return _resolve_latest_release(client) - with httpx.Client(timeout=10.0, follow_redirects=True) as owned_client: + with httpx.Client(timeout=_REQUEST_TIMEOUT, follow_redirects=True) as owned_client: return _resolve_latest_release(owned_client) +def fetch_latest_version(client: httpx.Client | None = None) -> str: + """Return the latest stable version, resolved without touching the API quota. + + The update notice needs a version string and nothing else -- no payload, no wheel + URL -- so it stays off `api.github.com` entirely. That budget is 60 requests per + hour per IP for unauthenticated callers, and a daily check from every install + behind one egress is what drains it. + """ + if client is not None: + return _fetch_latest_version_via_redirect(client) + with httpx.Client(timeout=_REQUEST_TIMEOUT, follow_redirects=True) as owned_client: + return _fetch_latest_version_via_redirect(owned_client) + + def _direct_url_data() -> dict[str, object] | None: raw = metadata.distribution("raven").read_text("direct_url.json") if raw is None: diff --git a/tests/test_cli_update_notice.py b/tests/test_cli_update_notice.py index 4c9ce3d8..a8b4ab91 100644 --- a/tests/test_cli_update_notice.py +++ b/tests/test_cli_update_notice.py @@ -164,23 +164,35 @@ def _boom(): def test_successful_refresh_records_fetched_version(cache, monkeypatch): - monkeypatch.setitem(sys.modules, "raven.cli.upgrade_commands", _FakeUpgrade(lambda: _Release("0.3.0"))) + monkeypatch.setitem(sys.modules, "raven.cli.upgrade_commands", _FakeUpgrade(lambda: "0.3.0")) un._refresh() saved = json.loads(cache.read_text(encoding="utf-8")) assert saved["latest_version"] == "0.3.0" +def test_refresh_uses_the_quota_free_lookup(cache, monkeypatch): + # The daily check runs on every install behind a shared egress; routing it through + # the API is what drains the 60/hour unauthenticated bucket. + import raven.cli.upgrade_commands as upgrade + + monkeypatch.setattr(upgrade, "_fetch_latest_release", _forbidden_api_call) + monkeypatch.setattr(upgrade, "fetch_latest_version", lambda: "0.4.0") + un._refresh() + + saved = json.loads(cache.read_text(encoding="utf-8")) + assert saved["latest_version"] == "0.4.0" + + +def _forbidden_api_call(*args, **kwargs): + raise AssertionError("the update notice must not touch the GitHub API") + + class _FakeThread: def start(self): # noqa: D102 - test double pass -class _Release: - def __init__(self, version: str) -> None: - self.version = version - - class _FakeUpgrade: """Stands in for raven.cli.upgrade_commands during _refresh().""" @@ -189,7 +201,7 @@ class _FakeUpgrade: def __init__(self, fetch) -> None: self._fetch = fetch - def _fetch_latest_release(self): # noqa: D102 - test double + def fetch_latest_version(self): # noqa: D102 - test double return self._fetch() def _version_key(self, value): # noqa: D102 - test double diff --git a/tests/test_cli_upgrade_commands.py b/tests/test_cli_upgrade_commands.py index 3087883d..f4f4436d 100644 --- a/tests/test_cli_upgrade_commands.py +++ b/tests/test_cli_upgrade_commands.py @@ -289,6 +289,74 @@ def handler(request: httpx.Request) -> httpx.Response: assert "check your network" not in message +def test_fetch_latest_release_reports_a_secondary_rate_limit_retry_after( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # The secondary (abuse) limit leaves the primary budget untouched, so the + # remaining==0 branch does not apply and retry-after is the only actionable fact. + monkeypatch.setattr(upgrade_commands, "_current_version", lambda: "0.1.3") + + def handler(request: httpx.Request) -> httpx.Response: + if str(request.url) == upgrade_commands.LATEST_RELEASE_API: + return httpx.Response(403, headers={"retry-after": "60", "x-ratelimit-remaining": "42"}) + return httpx.Response(503) + + with httpx.Client(transport=httpx.MockTransport(handler)) as client: + with pytest.raises(upgrade_commands.ReleaseLookupError) as excinfo: + upgrade_commands._fetch_latest_release(client) + + assert "retry in 60s" in str(excinfo.value) + + +def test_fetch_latest_version_stays_off_the_api_and_sends_no_head( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # The update notice runs daily on every install; it must not spend API budget. + monkeypatch.setattr(upgrade_commands, "_current_version", lambda: "0.1.3") + requested: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requested.append(f"{request.method} {request.url}") + return httpx.Response(302, headers={"location": f"{upgrade_commands.RELEASE_TAG_PREFIX}v0.1.4"}) + + with httpx.Client(transport=httpx.MockTransport(handler)) as client: + version = upgrade_commands.fetch_latest_version(client) + + assert version == "0.1.4" + assert requested == [f"GET {upgrade_commands.LATEST_RELEASE_WEB}"] + + +def test_release_page_requests_identify_raven(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(upgrade_commands, "_current_version", lambda: "0.1.3") + agents: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + agents.append(request.headers.get("User-Agent", "")) + if str(request.url) == upgrade_commands.LATEST_RELEASE_API: + return _quota_exhausted_response() + if str(request.url) == upgrade_commands.LATEST_RELEASE_WEB: + return httpx.Response(302, headers={"location": f"{upgrade_commands.RELEASE_TAG_PREFIX}v0.1.4"}) + return httpx.Response(200) + + with httpx.Client(transport=httpx.MockTransport(handler)) as client: + upgrade_commands._fetch_latest_release(client) + + assert agents == ["raven/0.1.3", "raven/0.1.3", "raven/0.1.3"] + + +@pytest.mark.parametrize( + ("message", "expected"), + [ + ("no punctuation", "no punctuation."), + ("already ends.", "already ends."), + ("waiting for the runner...", "waiting for the runner..."), + ], + ids=["bare", "terminated", "ellipsis"], +) +def test_sentence_adds_one_period_without_eating_an_ellipsis(message: str, expected: str) -> None: + assert upgrade_commands._sentence(ValueError(message)) == expected + + def test_fetch_latest_release_rejects_prerelease_tag_from_release_page( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -305,7 +373,7 @@ def handler(request: httpx.Request) -> httpx.Response: with pytest.raises(upgrade_commands.ReleaseLookupError) as excinfo: upgrade_commands._fetch_latest_release(client) - assert "Unsupported Raven version: v0.1.5-rc1" in str(excinfo.value) + assert "Unsupported Raven version: 'v0.1.5-rc1'" in str(excinfo.value) assert not any(entry.startswith("HEAD") for entry in requested) @@ -323,7 +391,12 @@ def handler(request: httpx.Request) -> httpx.Response: with pytest.raises(upgrade_commands.ReleaseLookupError) as excinfo: upgrade_commands._fetch_latest_release(client) - assert "release page: HTTP 404" in str(excinfo.value) + # The release page answered correctly (302); only the wheel was missing, and the + # message must not blame the page for it. + message = str(excinfo.value) + assert "release 0.1.4 has no wheel at the expected URL" in message + assert "HTTP 404" in message + assert "release page: HTTP 404" not in message def test_fetch_latest_release_does_not_route_around_a_prerelease_payload( @@ -347,18 +420,47 @@ def handler(request: httpx.Request) -> httpx.Response: assert requested == [upgrade_commands.LATEST_RELEASE_API] -def test_fetch_latest_release_propagates_invalid_json() -> None: +def test_fetch_latest_release_falls_back_when_the_api_body_is_not_json( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # A proxy or captive portal answering 200 with HTML is a remote failure, so it has + # to reach the fallback rather than surface a raw JSON-parser message. + monkeypatch.setattr(upgrade_commands, "_current_version", lambda: "0.1.3") + requested: list[str] = [] + def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response( - 200, - content=b"{not-json", - headers={"Content-Type": "application/json"}, - ) + requested.append(f"{request.method} {request.url}") + if str(request.url) == upgrade_commands.LATEST_RELEASE_API: + return httpx.Response(200, content=b"blocked by proxy") + if str(request.url) == upgrade_commands.LATEST_RELEASE_WEB: + return httpx.Response(302, headers={"location": f"{upgrade_commands.RELEASE_TAG_PREFIX}v0.1.4"}) + return httpx.Response(200) with httpx.Client(transport=httpx.MockTransport(handler)) as client: - with pytest.raises(ValueError): + release = upgrade_commands._fetch_latest_release(client) + + assert release == upgrade_commands.ReleaseInfo(version="0.1.4", wheel_url=WHEEL_URL) + assert any(entry.startswith(f"GET {upgrade_commands.LATEST_RELEASE_WEB}") for entry in requested) + + +def test_fetch_latest_release_reports_a_non_json_body_when_the_fallback_also_fails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(upgrade_commands, "_current_version", lambda: "0.1.3") + + def handler(request: httpx.Request) -> httpx.Response: + if str(request.url) == upgrade_commands.LATEST_RELEASE_API: + return httpx.Response(200, content=b"blocked by proxy") + return httpx.Response(503) + + with httpx.Client(transport=httpx.MockTransport(handler)) as client: + with pytest.raises(upgrade_commands.ReleaseLookupError) as excinfo: upgrade_commands._fetch_latest_release(client) + message = str(excinfo.value) + assert "non-JSON body" in message + assert "check your network" not in message + def _patch_available_release(monkeypatch: pytest.MonkeyPatch) -> upgrade_commands.ReleaseInfo: release = upgrade_commands.ReleaseInfo("0.1.4", WHEEL_URL) From 97317905acb67c255d0f4459c39161e45df3bb8a Mon Sep 17 00:00:00 2001 From: KT Date: Wed, 12 Aug 2026 17:32:30 +0800 Subject: [PATCH 3/3] fix(cli): let the caller set the release page timeout Review on #299: _fetch_latest_version_via_redirect hard-coded the fallback budget, so its two callers could not disagree. The update notice makes that request and nothing else, from a daemon thread with a 24h TTL and nobody waiting on it, yet it got the impatient 5s while the _REQUEST_TIMEOUT its own client asks for was unreachable -- a per-request override always wins. A slow link therefore made the notice give up and then back off for a full day. The timeout is a keyword argument now: the upgrade fallback passes 5s, because it is the second and third of three sequential requests, and the notice keeps 10s. A test pins all four request budgets by equality. Co-authored-by: Claude (claude-opus-5[1m]) --- raven/cli/upgrade_commands.py | 12 ++++++++---- tests/test_cli_upgrade_commands.py | 23 +++++++++++++++++++++++ 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/raven/cli/upgrade_commands.py b/raven/cli/upgrade_commands.py index ffab9ff2..cbd0e5a7 100644 --- a/raven/cli/upgrade_commands.py +++ b/raven/cli/upgrade_commands.py @@ -305,13 +305,17 @@ def _fetch_latest_release_via_api(client: httpx.Client) -> ReleaseInfo: return _parse_release_payload(payload) -def _fetch_latest_version_via_redirect(client: httpx.Client) -> str: - """Read the latest stable version off the release page, which no API quota applies to.""" +def _fetch_latest_version_via_redirect(client: httpx.Client, *, timeout: float = _REQUEST_TIMEOUT) -> str: + """Read the latest stable version off the release page, which no API quota applies to. + + The timeout belongs to the caller: this is one of three sequential requests when + `raven upgrade` falls back, but the only request the update notice makes. + """ response = client.get( LATEST_RELEASE_WEB, headers={"User-Agent": _user_agent()}, follow_redirects=False, - timeout=_FALLBACK_TIMEOUT, + timeout=timeout, ) location = response.headers.get("location", "") tag = location[len(RELEASE_TAG_PREFIX) :] if location.startswith(RELEASE_TAG_PREFIX) else "" @@ -321,7 +325,7 @@ def _fetch_latest_version_via_redirect(client: httpx.Client) -> str: def _fetch_latest_release_via_redirect(client: httpx.Client) -> ReleaseInfo: - version = _fetch_latest_version_via_redirect(client) + version = _fetch_latest_version_via_redirect(client, timeout=_FALLBACK_TIMEOUT) wheel_url = _release_wheel_url(version) try: client.head( diff --git a/tests/test_cli_upgrade_commands.py b/tests/test_cli_upgrade_commands.py index f4f4436d..88ca0ae6 100644 --- a/tests/test_cli_upgrade_commands.py +++ b/tests/test_cli_upgrade_commands.py @@ -326,6 +326,29 @@ def handler(request: httpx.Request) -> httpx.Response: assert requested == [f"GET {upgrade_commands.LATEST_RELEASE_WEB}"] +def test_request_timeouts_follow_the_caller_not_the_helper(monkeypatch: pytest.MonkeyPatch) -> None: + # The notice makes one request and can afford the full budget; the upgrade fallback + # is the second and third of three and must not triple the wait. + monkeypatch.setattr(upgrade_commands, "_current_version", lambda: "0.1.3") + timeouts: list[float | None] = [] + + def handler(request: httpx.Request) -> httpx.Response: + timeouts.append(request.extensions.get("timeout", {}).get("connect")) + if str(request.url) == upgrade_commands.LATEST_RELEASE_API: + return _quota_exhausted_response() + if str(request.url) == upgrade_commands.LATEST_RELEASE_WEB: + return httpx.Response(302, headers={"location": f"{upgrade_commands.RELEASE_TAG_PREFIX}v0.1.4"}) + return httpx.Response(200) + + with httpx.Client(transport=httpx.MockTransport(handler), timeout=upgrade_commands._REQUEST_TIMEOUT) as client: + upgrade_commands._fetch_latest_release(client) + upgrade_commands.fetch_latest_version(client) + + fallback = upgrade_commands._FALLBACK_TIMEOUT + request_timeout = upgrade_commands._REQUEST_TIMEOUT + assert timeouts == [request_timeout, fallback, fallback, request_timeout] + + def test_release_page_requests_identify_raven(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(upgrade_commands, "_current_version", lambda: "0.1.3") agents: list[str] = []