diff --git a/crates/graphicle/src/model.rs b/crates/graphicle/src/model.rs index 542b5342..b1cd2a0b 100644 --- a/crates/graphicle/src/model.rs +++ b/crates/graphicle/src/model.rs @@ -290,11 +290,20 @@ pub fn checked_url(value: &str) -> Result { } Ok(url) } +/// `localhost` or any loopback address: 127.0.0.0/8, `::1`, and IPv4-mapped +/// 127.0.0.0/8. fn loopback(url: &url::Url) -> bool { - matches!( - url.host_str(), - Some("localhost" | "127.0.0.1" | "[::1]" | "::1") - ) + match url.host() { + Some(url::Host::Domain(host)) => host == "localhost", + Some(url::Host::Ipv4(address)) => address.is_loopback(), + Some(url::Host::Ipv6(address)) => { + address.is_loopback() + || address + .to_ipv4_mapped() + .is_some_and(|mapped| mapped.is_loopback()) + } + None => false, + } } /// No user name, password, query or fragment. fn bare(url: &url::Url) -> bool { @@ -1578,6 +1587,32 @@ mod tests { assert!(checked_url("http://192.168.210.33:8000/v1").is_err()); } + #[test] + fn plain_http_reaches_every_loopback_address_and_nothing_else() { + for url in [ + "http://localhost:11434/v1", + "http://127.0.0.1:11434/v1", + "http://127.0.0.2:11434/v1", + "http://127.255.255.254/v1", + "http://[::1]:8080/v1", + "http://[::ffff:127.0.0.1]:8080/v1", + ] { + assert!(checked_url(url).is_ok(), "{url}"); + assert!(provider_url_with(url, true, &[]).is_ok(), "{url}"); + } + for url in [ + "http://128.0.0.1/v1", + "http://126.255.255.255/v1", + "http://0.0.0.0/v1", + "http://[::2]/v1", + "http://[::ffff:10.0.0.1]/v1", + "http://127.0.0.1.example.com/v1", + "http://localhost.example.com/v1", + ] { + assert!(checked_url(url).is_err(), "{url}"); + } + } + #[test] fn the_plain_http_host_list_takes_exact_hosts_only() { assert!(http_hosts("").unwrap().is_empty()); diff --git a/crates/graphicle/tests/test_sdk_retry_after.py b/crates/graphicle/tests/test_sdk_retry_after.py new file mode 100644 index 00000000..5b0f9931 --- /dev/null +++ b/crates/graphicle/tests/test_sdk_retry_after.py @@ -0,0 +1,77 @@ +"""A spent budget keeps Retry-After so the caller knows when to try again.""" +import sys +import threading +from datetime import datetime, timedelta, timezone +from email.utils import format_datetime +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[3] / "sdk" / "python")) +from graphicle_sdk import Client, GraphicleError + + +class _Budget(BaseHTTPRequestHandler): + def do_GET(self): + body = b'{"error":"monthly request budget is spent"}' + self.send_response(429) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + if self.path == "/seconds": + self.send_header("Retry-After", "3600") + elif self.path == "/date": + when = datetime.now(timezone.utc) + timedelta(hours=2) + self.send_header("Retry-After", format_datetime(when, usegmt=True)) + self.end_headers() + self.wfile.write(body) + + def log_message(self, _format, *_args): + return + + +def _client(): + server = ThreadingHTTPServer(("127.0.0.1", 0), _Budget) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + port = server.server_address[1] + client = Client(f"http://127.0.0.1:{port}", "workspace", "test-capability", timeout=5) + return server, client + + +def test_retry_after_seconds_is_kept_on_the_error(): + server, client = _client() + try: + with pytest.raises(GraphicleError) as caught: + client.request("GET", "/seconds") + finally: + server.shutdown() + error = caught.value + assert error.status == 429 + assert error.retry_after == 3600 + assert "3600" in str(error) + assert "retry after" in str(error) + + +def test_retry_after_http_date_is_seconds_from_now(): + server, client = _client() + try: + with pytest.raises(GraphicleError) as caught: + client.request("GET", "/date") + finally: + server.shutdown() + error = caught.value + assert 7000 <= error.retry_after <= 7400 + assert "retry after" in str(error) + + +def test_a_refusal_without_retry_after_stays_plain(): + server, client = _client() + try: + with pytest.raises(GraphicleError) as caught: + client.request("GET", "/plain") + finally: + server.shutdown() + error = caught.value + assert error.retry_after is None + assert "retry after" not in str(error) diff --git a/sdk/python/graphicle_sdk/client.py b/sdk/python/graphicle_sdk/client.py index e85634cf..a6327ac0 100644 --- a/sdk/python/graphicle_sdk/client.py +++ b/sdk/python/graphicle_sdk/client.py @@ -48,7 +48,8 @@ drop arguments whose value is ``None``; ``command`` sends exactly what it is given. Errors: an HTTP error response raises :class:`GraphicleError` with ``status``, ``message`` (the -service's ``error`` text) and ``body``. HTTP 403 raises :class:`GraphiclePermissionError`, which is +service's ``error`` text), ``body`` and, when the response sent ``Retry-After``, ``retry_after`` +in seconds. HTTP 403 raises :class:`GraphiclePermissionError`, which is also a built-in :class:`PermissionError`. A failed or cancelled job raises :class:`GraphicleError` with ``status=None`` and the job record in ``job``; an unreachable service raises it with ``status=None`` too. :meth:`Project.wait` raises :class:`TimeoutError`. @@ -60,6 +61,7 @@ """ from __future__ import annotations +import email.utils import ipaddress import json import math @@ -69,7 +71,7 @@ import urllib.error import urllib.parse import urllib.request -from datetime import date +from datetime import date, datetime, timezone from pathlib import Path from typing import Any, Dict, Iterable, Iterator, List, Mapping, Optional, Sequence, Union @@ -120,18 +122,24 @@ class GraphicleError(Exception): ``status`` is the HTTP status code, or ``None`` when there was no HTTP error response (the service was unreachable, or a job failed or was cancelled). ``message`` is the service's error text, ``body`` the parsed response body and ``job`` the job record for workflow failures. + ``retry_after`` is seconds from a ``Retry-After`` header (a delay, or an HTTP-date counted + from now), or ``None`` when the response did not send one. """ def __init__(self, status: Optional[int], message: str, *, body: Any = None, - job: Optional[Dict[str, Any]] = None) -> None: + job: Optional[Dict[str, Any]] = None, retry_after: Optional[int] = None) -> None: super().__init__(message) self.status = status self.message = message self.body = body self.job = job + self.retry_after = retry_after def __str__(self) -> str: - return self.message if self.status is None else f"{self.message} (HTTP {self.status})" + text = self.message if self.status is None else f"{self.message} (HTTP {self.status})" + if self.retry_after is None: + return text + return f"{text}; retry after {self.retry_after} s" class GraphiclePermissionError(GraphicleError, PermissionError): @@ -144,7 +152,23 @@ def redirect_request(self, req, fp, code, msg, headers, newurl): return None -def _error(status: int, raw: bytes) -> GraphicleError: +def _retry_after(headers: Any) -> Optional[int]: + raw = headers.get("Retry-After") if headers is not None and hasattr(headers, "get") else None + if not isinstance(raw, str): + return None + text = raw.strip() + if text.isdigit(): + return int(text) + parsed = email.utils.parsedate_to_datetime(text) + if parsed is None: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + delay = (parsed - datetime.now(timezone.utc)).total_seconds() + return 0 if delay <= 0 else math.ceil(delay) + + +def _error(status: int, raw: bytes, headers: Any = None) -> GraphicleError: text = raw.decode("utf-8", "replace").strip() try: body: Any = json.loads(text) if text else None @@ -155,7 +179,7 @@ def _error(status: int, raw: bytes) -> GraphicleError: else: message = text[:2000] or f"HTTP {status}" kind = GraphiclePermissionError if status == 403 else GraphicleError - return kind(status, message, body=body) + return kind(status, message, body=body, retry_after=_retry_after(headers)) def default_data_dir() -> Path: @@ -250,7 +274,7 @@ def request(self, method: str, path: str, body: Any = None, *, raw = b"" finally: error.close() - raise _error(error.code, raw) from None + raise _error(error.code, raw, error.headers) from None except (urllib.error.URLError, OSError) as error: reason = getattr(error, "reason", error) if isinstance(reason, TimeoutError):