diff --git a/install.ps1 b/install.ps1 index b54b5de..9b84909 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 048ec4b..f9d5543 100755 --- a/install.sh +++ b/install.sh @@ -189,10 +189,42 @@ 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="" + # 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 + 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 + 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/update_notice.py b/raven/cli/update_notice.py index 7f72c6c..12bea5c 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 c6fe34e..cbd0e5a 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,7 +19,14 @@ 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]*)$") +_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() @@ -26,6 +34,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 @@ -178,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) @@ -187,64 +200,189 @@ 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" + + +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): + 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 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() + 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, *, 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=timeout, + ) + location = response.headers.get("location", "") + 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)) + + +def _fetch_latest_release_via_redirect(client: httpx.Client) -> ReleaseInfo: + version = _fetch_latest_version_via_redirect(client, timeout=_FALLBACK_TIMEOUT) + wheel_url = _release_wheel_url(version) + 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) + + +def _resolve_latest_release(client: httpx.Client) -> ReleaseInfo: + try: + return _fetch_latest_release_via_api(client) + except httpx.HTTPError as error: + # 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: + 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: + return _resolve_latest_release(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: - response = client.get(LATEST_RELEASE_API, headers=headers) - response.raise_for_status() - return _parse_release_payload(response.json()) - 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 _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: @@ -450,6 +588,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 +598,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_update_notice.py b/tests/test_cli_update_notice.py index 4c9ce3d..a8b4ab9 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 452ddd5..88ca0ae 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,36 +225,265 @@ 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_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 test_fetch_latest_release_propagates_invalid_json() -> None: def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response( - 200, - content=b"{not-json", - headers={"Content-Type": "application/json"}, - ) + 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(ValueError): + 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_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] = [] + + 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: + 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) + + # 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( + 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_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: + 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: + 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) @@ -868,9 +1126,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 +1235,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 +1294,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