Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 39 additions & 4 deletions crates/graphicle/src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -290,11 +290,20 @@ pub fn checked_url(value: &str) -> Result<url::Url> {
}
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 {
Expand Down Expand Up @@ -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());
Expand Down
77 changes: 77 additions & 0 deletions crates/graphicle/tests/test_sdk_retry_after.py
Original file line number Diff line number Diff line change
@@ -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)
38 changes: 31 additions & 7 deletions sdk/python/graphicle_sdk/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -60,6 +61,7 @@
"""
from __future__ import annotations

import email.utils
import ipaddress
import json
import math
Expand All @@ -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

Expand Down Expand Up @@ -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):
Expand All @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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):
Expand Down
Loading