From 2993932d74e5d2ca2c78b75c2a731ad6a20ba59d Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Tue, 23 Dec 2025 16:37:17 -0600 Subject: [PATCH 01/14] Add testing infrastructure and utilities for backends - Add LocalTestServer for httpbin-like backend testing - Enhance ViceroyTestBase with backend setup capabilities - Add utility functions for response body reading - Update build configuration and dependencies - Improve temporary file handling with NamedTemporaryFile - Add syrupy for snapshot testing. This provides the foundation for backend testing and includes utilities needed by the requests implementation. --- Makefile | 13 +- fastly_compute/test_server.py | 221 ++++++++++++++++++++++++++++++++++ fastly_compute/testing.py | 102 ++++++++++++++-- fastly_compute/utils.py | 36 ++++++ pyproject.toml | 11 ++ uv.lock | 25 ++++ 6 files changed, 396 insertions(+), 12 deletions(-) create mode 100644 fastly_compute/test_server.py create mode 100644 fastly_compute/utils.py diff --git a/Makefile b/Makefile index 6d4f87d..9370daa 100644 --- a/Makefile +++ b/Makefile @@ -10,7 +10,7 @@ EXAMPLES_DIR := examples COMPUTE_WIT := wit/deps/fastly/compute.wit # Define all available examples (add new ones here) -EXAMPLES := bottle-app flask-app game-of-life +EXAMPLES := bottle-app flask-app backend-requests game-of-life # Default example for serve target EXAMPLE ?= bottle-app @@ -18,6 +18,8 @@ WASM_FILE := $(BUILD_DIR)/$(EXAMPLE).composed.wasm TARGET_WORLD := fastly:compute/service +VICEROY ?= viceroy + # Generate WASM file paths for all examples EXAMPLE_WASMS := $(foreach example,$(EXAMPLES),$(BUILD_DIR)/$(example).wasm) @@ -57,7 +59,11 @@ serve: $(WASM_FILE) # Test all examples (requires all WASM files to be built) test: $(COMPOSED_WASMS) - uv run --extra test pytest + VICEROY=$(VICEROY) uv run --extra test pytest + +# Update snapshots for snapshot tests +test-update-snapshots: $(COMPOSED_WASMS) + VICEROY=$(VICEROY) uv run --extra test pytest --snapshot-update # List available examples list-examples: @@ -93,6 +99,7 @@ help: @echo " all Build all examples" @echo " serve [EXAMPLE=name] Serve example (default: $(EXAMPLE))" @echo " test Run integration tests (builds all examples)" + @echo " test-update-snapshots Update snapshot test baselines" @echo " build-all Build all examples (alias for 'all')" @echo " list-examples List available examples" @echo " clean Clean build artifacts" @@ -109,4 +116,4 @@ help: @echo "" @echo "Available examples: $(EXAMPLES)" -.PHONY: all serve test list-examples build-all clean lint lint-fix format format-check help $(WASILESS_WASM) +.PHONY: all serve test test-update-snapshots list-examples build-all clean lint lint-fix format format-check help $(WASILESS_WASM) diff --git a/fastly_compute/test_server.py b/fastly_compute/test_server.py new file mode 100644 index 0000000..f6efc60 --- /dev/null +++ b/fastly_compute/test_server.py @@ -0,0 +1,221 @@ +"""Local test server helper for backend testing. + +Provides a simple HTTP server that can act as a backend for viceroy testing. +""" + +import json +import threading +import time +from http.server import BaseHTTPRequestHandler, HTTPServer +from typing import Any +from urllib.parse import parse_qs, urlparse + + +class _BaseTestRequestHandler(BaseHTTPRequestHandler): + """Base HTTP request handler for test server. + + This is a template class - use make_test_request_handler() to create + a handler with specific responses configured. + """ + + # This will be overridden in subclasses created by the factory + configured_responses: dict[str, dict[str, Any]] = {} + + def do_GET(self): + """Handle GET requests.""" + self._handle_request("GET") + + def do_POST(self): + """Handle POST requests.""" + self._handle_request("POST") + + def do_PUT(self): + """Handle PUT requests.""" + self._handle_request("PUT") + + def do_DELETE(self): + """Handle DELETE requests.""" + self._handle_request("DELETE") + + def _handle_request(self, method: str): + """Generic request handler.""" + # Parse request + parsed_url = urlparse(self.path) + path = parsed_url.path + query_params = parse_qs(parsed_url.query) + + # Read request body for POST/PUT + content_length = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(content_length) if content_length > 0 else b"" + + # Check if we have a configured response for this path + responses = self.configured_responses + if path in responses: + configured_response = responses[path] + status = configured_response.get("status", 200) + headers = configured_response.get("headers", {}) + response_body = configured_response.get("body", {}) + else: + # Default httpbin-like response + status = 200 + headers = {"Content-Type": "application/json"} + + # Create httpbin-like response + request_headers = dict(self.headers) + + response_body = { + "args": { + k: v[0] if len(v) == 1 else v for k, v in query_params.items() + }, + "headers": request_headers, + "origin": self.client_address[0], + "url": f"http://{self.headers.get('Host', 'localhost')}{self.path}", + "method": method, + "path": path, + } + + # Add body data for POST/PUT + if body: + try: + # Try to parse as JSON + json_data = json.loads(body.decode("utf-8")) + response_body["json"] = json_data + except (json.JSONDecodeError, UnicodeDecodeError): + # Store as raw data + response_body["data"] = body.decode("utf-8", errors="replace") + + # Send response + self.send_response(status) + + # Set headers + if isinstance(headers, dict): + for header_name, header_value in headers.items(): + self.send_header(header_name, header_value) + + # Default content-type if not set + if "Content-Type" not in headers: + self.send_header("Content-Type", "application/json") + + self.end_headers() + + # Send body + if isinstance(response_body, dict | list): + response_json = json.dumps(response_body, indent=2) + self.wfile.write(response_json.encode("utf-8")) + else: + self.wfile.write(str(response_body).encode("utf-8")) + + def log_message(self, format, *args): + """Override to reduce log noise in tests.""" + + +def make_test_request_handler( + responses: dict[str, dict[str, Any]], +) -> type[BaseHTTPRequestHandler]: + """Create a request handler class with configured responses. + + Args: + responses: Dictionary mapping paths to response configurations + + Returns: + A new handler class with responses bound as a class attribute + """ + # Create a new class that inherits from our base handler + # and sets the responses as a class attribute + return type( + "TestRequestHandler", + (_BaseTestRequestHandler,), + {"configured_responses": responses}, + ) + + +class LocalTestServer: + """Local HTTP server for backend testing. + + This server can be used to mock external backends during testing. + It supports both httpbin-style behavior and custom response patterns. + """ + + def __init__( + self, + host: str = "127.0.0.1", + port: int = 0, + responses: dict[str, dict[str, Any]] | None = None, + ): + """Initialize the test server. + + Args: + host: The host interface to bind to (default: "127.0.0.1") + port: The port to bind to (default: 0 for auto-assignment) + responses: Optional dict mapping paths to response configs. + Each response config can contain: + - "status": HTTP status code (default: 200) + - "headers": Dict of HTTP headers + - "body": Response body (dict will be JSON-encoded) + Example: {"/api/test": {"status": 200, "body": {"success": True}}} + """ + self.host = host + self.port = port + self.responses = responses or {} + self.server: HTTPServer | None = None + self.thread: threading.Thread | None = None + + def start(self) -> str: + """Start the test server. + + Returns: + The base URL of the started server (e.g., "http://127.0.0.1:12345") + """ + if self.server is not None: + raise RuntimeError("Server is already running") + + # Create a handler class with our responses configured + handler_class = make_test_request_handler(self.responses) + + # Create server + self.server = HTTPServer((self.host, self.port), handler_class) + + # Get actual port (important when port=0 for auto-assignment) + # server_address returns (host, port) for IPv4, or (host, port, flowinfo, scopeid) for IPv6 + actual_port = self.server.server_address[1] # Port is always at index 1 + base_url = f"http://{self.host}:{actual_port}" + + # Start server in background thread + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + + # Wait a bit for server to be ready + time.sleep(0.1) + + return base_url + + def stop(self): + """Stop the test server.""" + if self.server is None: + return + + self.server.shutdown() + self.server.server_close() + self.server = None + + if self.thread and self.thread.is_alive(): + self.thread.join(timeout=1.0) + self.thread = None + + def __enter__(self): + """Context manager entry.""" + return self.start() + + def __exit__(self, exc_type, exc_val, exc_tb): + """Context manager exit.""" + self.stop() + + @property + def base_url(self) -> str: + """Get the base URL of the running server.""" + if self.server is None: + raise RuntimeError("Server is not running") + + # Port is always at index 1 regardless of address family + port = self.server.server_address[1] + return f"http://{self.host}:{port}" diff --git a/fastly_compute/testing.py b/fastly_compute/testing.py index 43c3a38..05e67d0 100644 --- a/fastly_compute/testing.py +++ b/fastly_compute/testing.py @@ -8,16 +8,18 @@ pytest_plugins = ["fastly_compute.pytest_plugin"] """ +import os import socket import subprocess import threading import time from dataclasses import dataclass -from os import environ from pathlib import Path +from tempfile import NamedTemporaryFile import pytest import requests +import tomli_w @dataclass @@ -56,9 +58,14 @@ def test_my_endpoint(self): @property def server(self) -> ViceroyServer: + """Access server properties.""" assert self._server is not None return self._server + # Configuration for backend testing + VICEROY_CONFIG = None # Dict with viceroy config, or None for no config + _config_file_path = None # Will store temp config file path + @staticmethod def _find_free_port() -> int: """Find an available port on localhost.""" @@ -67,6 +74,62 @@ def _find_free_port() -> int: port = s.getsockname()[1] return port + @classmethod + def _create_viceroy_config(cls, backends: dict[str, str] | None = None) -> str: + """Create a temporary viceroy configuration file. + + Args: + backends: Dict mapping backend names to URLs + e.g., {"httpbin": "http://127.0.0.1:8080"} + + Returns: + Path to the temporary configuration file + """ + config_dict = {} + + # Add backends if provided + if backends: + config_dict["local_server"] = { + "backends": {name: {"url": url} for name, url in backends.items()} + } + + # Add any additional config from class + if cls.VICEROY_CONFIG: + # Merge with class config + if "local_server" in config_dict and "local_server" in cls.VICEROY_CONFIG: + # Merge local_server sections + for key, value in cls.VICEROY_CONFIG["local_server"].items(): + if key == "backends" and "backends" in config_dict["local_server"]: + # Merge backends + config_dict["local_server"]["backends"].update(value) + else: + config_dict["local_server"][key] = value + else: + # Add other sections + config_dict.update(cls.VICEROY_CONFIG) + + # Generate TOML content + toml_content = tomli_w.dumps(config_dict) + + # Create temporary file + with NamedTemporaryFile( + prefix="viceroy_config_", suffix=".toml", mode="w", delete=False + ) as f: + f.write(toml_content) + return f.name + + @classmethod + def set_up_backends(cls, backends: dict[str, str]): + """Set up backends for testing. + + Call this in setUpClass or as a class-level setup. + + Args: + backends: Dict mapping backend names to URLs + e.g., {"httpbin": "http://127.0.0.1:8080"} + """ + cls._test_backends = backends + @pytest.fixture(scope="class", autouse=True) @classmethod def viceroy_server(cls): @@ -94,16 +157,29 @@ def viceroy_server(cls): output_lock = threading.Lock() stop_capture = threading.Event() + # Create config file if needed + config_file_path = None + if hasattr(cls, "_test_backends") or cls.VICEROY_CONFIG: + # Get backends from test setup (if any) + backends = getattr(cls, "_test_backends", None) + config_file_path = cls._create_viceroy_config(backends) + cls._config_file_path = config_file_path + + # Build viceroy command + viceroy_cmd = [ + os.getenv("VICEROY", "viceroy"), + "serve", + cls.WASM_FILE, + "--addr", + f"127.0.0.1:{port}", + "-v", + ] + if config_file_path: + viceroy_cmd.extend(["-C", config_file_path]) + # Start viceroy process process = subprocess.Popen( - [ - environ.get("VICEROY", "viceroy"), - "serve", - cls.WASM_FILE, - "--addr", - f"127.0.0.1:{port}", - "-v", - ], + viceroy_cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, @@ -177,6 +253,14 @@ def capture_output_thread(): process.kill() process.wait() + # Clean up config file if we created one + if cls._config_file_path and os.path.exists(cls._config_file_path): + try: + os.unlink(cls._config_file_path) + except OSError: + pass # Ignore cleanup errors + cls._config_file_path = None + def get(self, path: str, **kwargs) -> requests.Response: """Make a GET request to the viceroy server. diff --git a/fastly_compute/utils.py b/fastly_compute/utils.py new file mode 100644 index 0000000..1da4f60 --- /dev/null +++ b/fastly_compute/utils.py @@ -0,0 +1,36 @@ +"""Utility functions for fastly_compute package.""" + +from wit_world.imports import async_io, http_body +from wit_world.types import Err + +from fastly_compute.requests.exceptions import RequestException + + +def read_response_body( + response_body: async_io.Pollable, chunk_size: int = 4096 +) -> bytes: + """Read the complete response body from a WIT response body object. + + Args: + response_body: WIT response body object to read from + chunk_size: Size of chunks to read at a time (default: 4096) + + Returns: + Complete response body as bytes + + Raises: + RequestException: If there is a problem reading the response body. + """ + body_data: bytes = b"" + while True: + try: + chunk = http_body.read(response_body, chunk_size) + except Err as e: + raise RequestException.from_wit_error(e, "http_body.read") from e + + if len(chunk) == 0: + break + else: + body_data += chunk + + return body_data diff --git a/pyproject.toml b/pyproject.toml index c68dac0..ec078f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,8 @@ dependencies = [ test = [ "pytest (>=8.4.0,<9.0.0)", "requests (>=2.32.5,<3.0.0)", + "tomli-w (>=1.0.0,<2.0.0)", + "syrupy (==5.0.0)", ] dev = [ "ruff (>=0.12.11,<0.13.0)", @@ -44,6 +46,7 @@ select = [ "B", # flake8-bugbear "C4", # flake8-comprehensions "UP", # pyupgrade + "D", # docstrings ] ignore = [ "E501", # line too long, handled by formatter @@ -56,6 +59,14 @@ indent-style = "space" skip-magic-trailing-comma = false line-ending = "auto" +[tool.ruff.lint.pydocstyle] +convention = "google" + +# Ignore doc lints for tests/examples +[tool.ruff.lint.per-file-ignores] +"tests/*" = ["D"] +"examples/*" = ["D"] + [build-system] requires = [ "setuptools (>=80.9.0,<81.0.0)", diff --git a/uv.lock b/uv.lock index e3de0f5..99c2d58 100644 --- a/uv.lock +++ b/uv.lock @@ -138,6 +138,8 @@ dev = [ test = [ { name = "pytest" }, { name = "requests" }, + { name = "syrupy" }, + { name = "tomli-w" }, ] [package.metadata] @@ -149,6 +151,8 @@ requires-dist = [ { name = "pytest", marker = "extra == 'test'", specifier = ">=8.4.0,<9.0.0" }, { name = "requests", marker = "extra == 'test'", specifier = ">=2.32.5,<3.0.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.12.11,<0.13.0" }, + { name = "syrupy", marker = "extra == 'test'", specifier = "==5.0.0" }, + { name = "tomli-w", marker = "extra == 'test'", specifier = ">=1.0.0,<2.0.0" }, ] provides-extras = ["test", "dev"] @@ -371,6 +375,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/28/7e/61c42657f6e4614a4258f1c3b0c5b93adc4d1f8575f5229d1906b483099b/ruff-0.12.12-py3-none-win_arm64.whl", hash = "sha256:2a8199cab4ce4d72d158319b63370abf60991495fb733db96cd923a34c52d093", size = 12256762, upload-time = "2025-09-04T16:50:15.737Z" }, ] +[[package]] +name = "syrupy" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c1/90/1a442d21527009d4b40f37fe50b606ebb68a6407142c2b5cc508c34b696b/syrupy-5.0.0.tar.gz", hash = "sha256:3282fe963fa5d4d3e47231b16d1d4d0f4523705e8199eeb99a22a1bc9f5942f2", size = 48881, upload-time = "2025-09-28T21:15:12.783Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/9a/6c68aad2ccfce6e2eeebbf5bb709d0240592eb51ff142ec4c8fbf3c2460a/syrupy-5.0.0-py3-none-any.whl", hash = "sha256:c848e1a980ca52a28715cd2d2b4d434db424699c05653bd1158fb31cf56e9546", size = 49087, upload-time = "2025-09-28T21:15:11.639Z" }, +] + +[[package]] +name = "tomli-w" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, +] + [[package]] name = "urllib3" version = "2.5.0" From db42d69d810a4dcbecf9a9bbb68b3c406041bc33 Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Tue, 23 Dec 2025 16:37:30 -0600 Subject: [PATCH 02/14] Add requests-compatible API for interacting with backends - Implement fastly_compute.requests module with requests compatibility - Support for both static and dynamic backends - Advanced timeout configuration (connect, first_byte, between_bytes) - WIT error mapping to map to requests-compat exceptions (there are some known failures here due to how viceroy behaves). - requests-compatible response object with lazy loading - Add backend-requests example application - Comprehensive test suite with snapshot testing --- examples/backend-requests.py | 92 +++ fastly_compute/requests/__init__.py | 341 ++++++++++ fastly_compute/requests/backend.py | 137 ++++ fastly_compute/requests/exceptions.py | 225 +++++++ fastly_compute/requests/response.py | 195 ++++++ fastly_compute/requests/timeout.py | 91 +++ fastly_compute/wsgi.py | 6 + main.py | 6 - test.toml | 10 + .../__snapshots__/test_backend_requests.ambr | 76 +++ tests/test_backend_requests.py | 625 ++++++++++++++++++ 11 files changed, 1798 insertions(+), 6 deletions(-) create mode 100644 examples/backend-requests.py create mode 100644 fastly_compute/requests/__init__.py create mode 100644 fastly_compute/requests/backend.py create mode 100644 fastly_compute/requests/exceptions.py create mode 100644 fastly_compute/requests/response.py create mode 100644 fastly_compute/requests/timeout.py delete mode 100644 main.py create mode 100644 test.toml create mode 100644 tests/__snapshots__/test_backend_requests.ambr create mode 100644 tests/test_backend_requests.py diff --git a/examples/backend-requests.py b/examples/backend-requests.py new file mode 100644 index 0000000..a97938c --- /dev/null +++ b/examples/backend-requests.py @@ -0,0 +1,92 @@ +"""Example demonstrating interactions with backends via the requests facade""" + +import json +import traceback +import typing +from io import StringIO +from typing import Any + +from bottle import Bottle, request + +import fastly_compute.requests as requests +from fastly_compute.wsgi import WsgiHttpIncoming + +app = Bottle() + + +def _map_error(e: Exception): + """Return a standardized error response. + + Args: + e: The exception that occurred + demo: The demo/endpoint name for this error + """ + exc_print = StringIO() + traceback.print_exc(file=exc_print) + return { + "error": repr(e), + "error_type": type(e).__name__, + "tb": exc_print.getvalue(), + } + + +def _proxy_request(method: str, url: str, **kwargs) -> dict[str, Any]: + try: + response = requests.request(method, url, **kwargs) + except Exception as e: + return _map_error(e) + + return { + "method": method, + "url": url, + "backend": kwargs.get("backend", None), + "status_code": response.status_code, + "success": response.ok, + "headers_count": len(response.headers), + "content_length": len(response.content), + "response_preview": response.text[:200] + "..." + if len(response.text) > 200 + else response.text, + } + + +@app.route("/proxy/") +def proxy(method: str): + """Proxy endpoint for testing; pass through parameters. + + Don't deploy actual code that does this as it is likely + to be abused if allowed to hit unrestricted domains. + This proxy method is intended for testing use. + """ + # Type checker doesn't quite understand bottle's DictProperty + # so we need to give it hints. + query = typing.cast(typing.Mapping[str, str], request.query) + headers_dict = typing.cast(typing.Mapping[str, str], request.headers) + + url: str | None = query.get("url") + if not url: + return {"error": "url query parameter is required"} + + backend = query.get("backend", None) + json_param = query.get("json", None) + + # Reconstitute JSON parameter if present + json_data = None + if json_param: + try: + json_data = json.loads(json_param) + except json.JSONDecodeError as e: + return {"error": f"Invalid JSON in json parameter: {e}"} + + headers = {} + for k, v in headers_dict.items(): + if k.lower() != "host": + headers[k] = v + + return _proxy_request( + method, url=url, backend=backend, json=json_data, headers=headers + ) + + +# Create the HTTP handler +HttpIncoming = WsgiHttpIncoming(app) diff --git a/fastly_compute/requests/__init__.py b/fastly_compute/requests/__init__.py new file mode 100644 index 0000000..70958cd --- /dev/null +++ b/fastly_compute/requests/__init__.py @@ -0,0 +1,341 @@ +"""A requests-compatible HTTP client for Fastly Compute. + +This module provides a familiar requests-like API while leveraging Fastly's +backend architecture and WIT bindings for optimal performance. + +Basic Usage: + import fastly_compute.requests as requests + + # Static backend (pre-configured) + response = requests.get("/api/users", backend="api-backend") + + # Dynamic backend (external URLs) + response = requests.get("https://http-me.fastly.dev/get") + + # POST with JSON + response = requests.post("https://http-me.fastly.dev/post", + json={"key": "value"}) + +Fastly-Specific Features: + from fastly_compute.requests import TimeoutConfig + + # Granular timeout control (not available in standard requests) + timeout_config = TimeoutConfig( + connect=5.0, # 5s to establish connection + first_byte=30.0, # 30s to receive first byte + between_bytes=2.0 # 2s max between bytes + ) + response = requests.get( + "https://api.example.com/data", + timeout_config=timeout_config + ) + + # Backend-specific features + response = requests.get( + "/api/endpoint", + backend="my-backend" # Use specific static backend + ) + +Compatibility Notes: + Most parameters are compatible with the standard requests library. + Fastly-specific parameters (timeout_config, backend) will cause TypeErrors + if used with the standard requests library. Use the standard timeout + parameter for cross-platform compatibility. +""" + +import json as json_module +import urllib.parse +from typing import Any, TypedDict, Unpack + +from wit_world.imports import async_io, http_body, http_req +from wit_world.types import Err + +from fastly_compute.requests.backend import resolve_backend + +from .exceptions import ( + ConnectionError, + HTTPError, + MissingSchema, + RequestException, + Timeout, +) +from .response import FastlyResponse +from .timeout import TimeoutConfig + + +# TypedDict for common request parameters +class RequestKwargs(TypedDict, total=False): + """Common keyword arguments for all request methods.""" + + data: str | bytes | dict[str, Any] | None + json: Any | None + params: dict[str, Any] + headers: dict[str, str] + backend: str + timeout: None | float | tuple[float, float] + timeout_config: TimeoutConfig + + +# Export main components for public API +__all__ = [ + # Core request functions + "get", + "post", + "put", + "delete", + "head", + "options", + "request", + # Response class + "FastlyResponse", + # Timeout configuration + "TimeoutConfig", + # Exceptions + "RequestException", + "ConnectionError", + "HTTPError", + "Timeout", + "MissingSchema", +] + + +def get( + url: str, + **kwargs: Unpack[RequestKwargs], +) -> FastlyResponse: + """Send a GET request. + + Args: + url: URL for the request. Can be a path (for static backends) or full URL (for dynamic) + params: Query parameters to append to the URL + headers: HTTP headers to send with the request + backend: Static backend name (optional, will use dynamic backend if not provided) + timeout: Request timeout in seconds (requests-compatible). Can be: + - float: Single timeout for all phases + - (connect, read): Tuple for connect and read timeouts + timeout_config: **Fastly-only** Advanced timeout configuration with granular control + over connect_timeout, first_byte_timeout, and between_bytes_timeout + **kwargs: Additional arguments (for requests compatibility, ignored) + + Note: + The timeout_config parameter is Fastly-specific and will cause a TypeError + if used with the standard requests library. Use timeout for cross-platform compatibility. + + Raises: + RequestException: For general request errors + ConnectionError: For connection-related errors + Timeout: For timeout errors + ValueError: If both timeout and timeout_config are specified + """ + return request("GET", url, **kwargs) + + +def post( + url: str, + **kwargs: Unpack[RequestKwargs], +) -> FastlyResponse: + """Send a POST request. + + Args: + url: URL for the request + data: Form data to send in the body + json: JSON data to send in the body (mutually exclusive with data) + params: Query parameters to append to the URL + headers: HTTP headers to send with the request + backend: Static backend name (optional) + timeout: Request timeout in seconds (requests-compatible) + timeout_config: **Fastly-only** Advanced timeout configuration + **kwargs: Additional arguments (for requests compatibility, ignored) + """ + return request("POST", url, **kwargs) + + +def put( + url: str, + **kwargs: Unpack[RequestKwargs], +) -> FastlyResponse: + """Send a PUT request.""" + return request("PUT", url, **kwargs) + + +def delete( + url: str, + **kwargs: Unpack[RequestKwargs], +) -> FastlyResponse: + """Send a DELETE request.""" + return request("DELETE", url, **kwargs) + + +def patch( + url: str, + **kwargs: Unpack[RequestKwargs], +) -> FastlyResponse: + """Send a PATCH request.""" + return request("PATCH", url, **kwargs) + + +def head(url: str, **kwargs: Unpack[RequestKwargs]) -> FastlyResponse: + """Send a HEAD request.""" + return request("HEAD", url, **kwargs) + + +def options(url: str, **kwargs: Unpack[RequestKwargs]) -> FastlyResponse: + """Send an OPTIONS request.""" + return request("OPTIONS", url, **kwargs) + + +def _http_body_write_all(body: async_io.Pollable, buf: bytes): + written = 0 + while written < len(buf): + written += http_body.write(body, buf) + + +def request( + method: str, + url: str, + params: dict[str, Any] | None = None, + data: str | bytes | dict[str, Any] | None = None, + json: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + backend: str | None = None, + timeout: None | float | tuple[float, float] = None, + timeout_config: TimeoutConfig | None = None, + **_kwargs: Any, +) -> FastlyResponse: + """Send an HTTP request. + + Args: + method: HTTP method (GET, POST, PUT, DELETE, etc.) + url: URL for the request + params: Query parameters + data: Form data for the request body + json: JSON data for the request body (mutually exclusive with data) + headers: HTTP headers + backend: Static backend name (if not provided, will use dynamic backend) + timeout: Request timeout in seconds (requests-compatible) + timeout_config: **Fastly-only** Advanced timeout configuration + **kwargs: Additional arguments (for requests compatibility, ignored) + + Raises: + RequestException: For general request errors + ValueError: For invalid arguments + """ + # Validate arguments + if data is not None and json is not None: + raise ValueError("Cannot specify both 'data' and 'json' parameters") + + if timeout is not None and timeout_config is not None: + raise ValueError( + "Cannot specify both 'timeout' and 'timeout_config' parameters" + ) + + # Resolve timeout configuration + if timeout_config is not None: + timeout_config = timeout_config + else: + timeout_config = TimeoutConfig.from_requests_timeout(timeout) + + try: + resolution = resolve_backend(url, backend, timeout_config) + except RequestException: + # Let RequestException subclasses (MissingSchema, etc.) pass through unchanged + raise + except ValueError as e: + # Other ValueError cases (e.g., backend not found) + raise RequestException(f"Backend resolution failed: {e}") from e + + url_parsed = resolution.url_parsed + + # Add query parameters if provided + if params: + query_params = urllib.parse.parse_qs(url_parsed.query) + for key, value in params.items(): + if isinstance(value, list): + query_params[key] = value + else: + query_params[key] = [str(value)] + + new_query = urllib.parse.urlencode(query_params, doseq=True) + url_parsed = url_parsed._replace(query=new_query) + + # Create WIT request + try: + wit_request = http_req.Request.new() + wit_request.set_method(method.upper()) + wit_request.set_uri(url_parsed.geturl()) + except Err as e: + raise RequestException.from_wit_error(e, "create_req") from e + + # Set headers + try: + # TODO: See https://github.com/fastly/Viceroy/pull/549; what is + # present here is a temporary workaround for viceroy differing + # in its handling than XQD. + # + # We'll always set a host header in the following order here: + # - If the header is set explicitly, use that + # - Use the netloc on the parsed url which comes from: + # - The netloc for this request OR + # - The netloc from the registered backend + headers = headers if headers is not None else {} + if backend is not None: + host_header = headers.pop("Host", url_parsed.netloc) + wit_request.insert_header("Host", host_header.encode("utf-8")) + + # Set default User-Agent only if not provided + # Check for both exact case and lowercase variants + has_user_agent = any(name.lower() == "user-agent" for name in headers.keys()) + if not has_user_agent: + wit_request.insert_header("User-Agent", b"FastlyCompute-Requests/1.0") + + # Add custom headers + if headers: + for name, value in headers.items(): + wit_request.insert_header(name, value.encode("utf-8")) + except (ValueError, UnicodeError) as e: + raise RequestException(f"Invalid headers: {e}") from e + except Err as e: + raise RequestException.from_wit_error(e, "set_request_headers") from e + + # Prepare request body + try: + request_body = http_body.new() + except Err as e: + raise RequestException.from_wit_error(e, "http_body.new") from e + + try: + if json is not None: + # JSON data - use the json module, not the parameter + json_str = json_module.dumps(json) if not isinstance(json, str) else json + json_bytes = json_str.encode("utf-8") + wit_request.insert_header("Content-Type", b"application/json") + _http_body_write_all(request_body, json_bytes) + elif data is None: + pass + elif isinstance(data, dict): + # Form data + form_data = urllib.parse.urlencode(data).encode("utf-8") + wit_request.insert_header( + "Content-Type", b"application/x-www-form-urlencoded" + ) + _http_body_write_all(request_body, form_data) + else: + # str | bytes + data_bytes = data.encode("utf-8") if isinstance(data, str) else data + _http_body_write_all(request_body, data_bytes) + except (TypeError, ValueError, UnicodeError) as e: + raise RequestException(f"Invalid request body: {e}") from e + except Err as e: + raise RequestException.from_wit_error(e, "write_body") from e + + # Send the request + try: + wit_response, response_body = http_req.send( + wit_request, request_body, resolution.backend + ) + except Err as e: + # WIT-level errors during request execution - use proper error classification + raise RequestException.from_http_req_error(e, "http_req.send") from e + + # Wrap in FastlyResponse + return FastlyResponse(wit_response, response_body, url_parsed.geturl()) diff --git a/fastly_compute/requests/backend.py b/fastly_compute/requests/backend.py new file mode 100644 index 0000000..19965cc --- /dev/null +++ b/fastly_compute/requests/backend.py @@ -0,0 +1,137 @@ +"""Backend resolution logic for fastly_compute.requests. + +Handles the logic for determining whether to use static or dynamic backends +based on URL patterns and backend availability. +""" + +from __future__ import annotations + +import urllib.parse +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from wit_world.imports import backend as wit_backend +from wit_world.imports.types import OpenError +from wit_world.types import Err + +from .exceptions import MissingSchema, RequestException + +if TYPE_CHECKING: + from .timeout import TimeoutConfig + +# global (per instance) set of registered dynamic backends +_dynamic_backends: set[str] = set() + + +@dataclass +class BackendResolution: + """Result of a successful backend resolution.""" + + url_parsed: urllib.parse.ParseResult + backend: wit_backend.Backend + + +def resolve_backend( + url: str, + backend: str | None = None, + timeout_config: TimeoutConfig | None = None, +) -> BackendResolution: + """Resolve backend name and final URL for a request. + + Args: + url: The URL to request (can be path-only or full URL) + backend: Optional static backend name + timeout_config: Optional timeout configuration for dynamic backends + + Returns: + ResolutionResult containing backend and updated parsed url + + Raises: + RequestException: If backend resolution fails + ValueError: If inputs are malformed + """ + parsed = urllib.parse.urlparse(url) + + backend_obj: wit_backend.Backend + if backend is not None: + # Check if backend exists by trying to open it + try: + backend_obj = wit_backend.Backend.open(backend) + except Err as e: + # Check if this is an OpenError (backend not found) + if isinstance(e.value, OpenError): + raise ValueError(f"Static backend '{backend}' does not exist") from e + # Re-raise if it's a different error + raise + else: + if not parsed.scheme or not parsed.netloc: + raise MissingSchema( + f"Invalid URL {url!r}: No scheme supplied. " + f"Perhaps you meant https://{url}?" + ) + + # Register dynamic backend if not already registered + backend_name = _sanitize_backend_name(parsed) + timeout_config = timeout_config or TimeoutConfig() + if backend_name not in _dynamic_backends: + backend_obj = _register_dynamic_backend( + backend_name, parsed, timeout_config + ) + _dynamic_backends.add(backend_name) + else: + # Open the already-registered backend + backend_obj = wit_backend.Backend.open(backend_name) + + if parsed.netloc == "": + host = backend_obj.get_host(1024) + parsed = parsed._replace(netloc=host) + + return BackendResolution(url_parsed=parsed, backend=backend_obj) + + +def _register_dynamic_backend( + backend_name: str, + parsed_url: urllib.parse.ParseResult, + timeout_config: TimeoutConfig, +) -> wit_backend.Backend: + options = wit_backend.DynamicBackendOptions() + + # Configure TLS for HTTPS + if parsed_url.scheme == "https": + options.use_tls(True) + # Set SNI to the hostname for proper certificate validation + options.sni_hostname(parsed_url.hostname or parsed_url.netloc) + + # Set timeouts from configuration (convert to milliseconds) + options.connect_timeout(timeout_config.connect_ms) + options.first_byte_timeout(timeout_config.first_byte_ms) + options.between_bytes_timeout(timeout_config.between_bytes_ms) + + # Register the backend + try: + return wit_backend.register_dynamic_backend( + prefix=backend_name, target=parsed_url.netloc, options=options + ) + except Err as e: + raise RequestException.from_wit_error(e, "register_dynamic_backend") from e + + +def _sanitize_backend_name(parsed: urllib.parse.ParseResult) -> str: + # Replace dots, colons, and other special chars with underscores + # Keep only alphanumeric chars and underscores + sanitized = "" + for char in parsed.netloc.lower(): + if char.isalnum(): + sanitized += char + elif char in ".-:": + sanitized += "_" + + # Remove multiple consecutive underscores if present + while "__" in sanitized: + sanitized = sanitized.replace("__", "_") + + # Remove leading/trailing underscores + sanitized = sanitized.strip("_") + assert sanitized, "Generated an errant empty backend name!" + + return sanitized diff --git a/fastly_compute/requests/exceptions.py b/fastly_compute/requests/exceptions.py new file mode 100644 index 0000000..7d2ca47 --- /dev/null +++ b/fastly_compute/requests/exceptions.py @@ -0,0 +1,225 @@ +"""Exceptions for fastly_compute.requests - compatible with requests library.""" + +from __future__ import annotations + +from types import MappingProxyType +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from wit_world.types import Err as WitErr + + from .response import FastlyResponse + +# Runtime imports needed for error mappings at module level +from wit_world.imports import http_req +from wit_world.imports import types as wit_types +from wit_world.imports.http_req import SendErrorDetail + + +class RequestException(IOError): + """Base exception for all requests-related errors.""" + + def __init__( + self, + message: str, + response: FastlyResponse | None = None, + request: http_req.Request | None = None, + ) -> None: + """Initialize RequestException. + + Args: + message: Error message + response: Optional response object that caused the error + request: Optional request object that caused the error + """ + super().__init__(message) + self.response: FastlyResponse | None = response + self.request: http_req.Request | None = request + + @classmethod + def from_http_req_error( + cls, err: WitErr[http_req.ErrorWithDetail], operation: str + ) -> RequestException: + """Create appropriate exception from http_req WIT error. + + Args: + err: WIT Err exception containing ErrorWithDetail + operation: Description of what operation failed + + Returns: + Appropriate RequestException subclass instance + """ + error_with_detail = err.value + + # Try detailed error classification first; this is not guaranteed + # to be present in all cases. + if error_with_detail.detail is not None: + send_error_type = type(error_with_detail.detail) + requests_exc_type = WIT_SEND_ERROR_DETAIL_MAPPING.get(send_error_type, cls) + return requests_exc_type(f"{operation}: {send_error_type.__name__}") + + # No detailed error - classify based on base error type + base_error_type = type(error_with_detail.error) + requests_exc_type: type[RequestException] = WIT_ERROR_MAPPINGS.get( + base_error_type, cls + ) + return requests_exc_type(f"{operation}: {base_error_type.__name__}") + + @classmethod + def from_wit_error( + cls, err: WitErr[wit_types.Error], operation: str + ) -> RequestException: + """Create appropriate exception from generic WIT error. + + Args: + err: WIT Err exception containing generic Error + operation: Description of what operation failed + + Returns: + Appropriate RequestException subclass instance + """ + error_type = type(err.value) + exception_class = WIT_ERROR_MAPPINGS.get(error_type, cls) + message = f"Operation {operation} failed: {error_type.__name__}" + return exception_class(message) + + +class ConnectionError(RequestException): + """Exception for connection-related errors.""" + + +class Timeout(RequestException): + """Exception for timeout errors.""" + + +class HTTPError(RequestException): + """Exception for HTTP error responses (4xx, 5xx status codes).""" + + def __init__( + self, + message: str, + response: FastlyResponse | None = None, + request: http_req.Request | None = None, + ) -> None: + """Initialize HTTPError. + + Args: + message: Error message + response: Response object that caused the error + request: Request object that caused the error + """ + super().__init__(message, response, request) + + +class TooManyRedirects(RequestException): + """Exception for too many redirects.""" + + +class InvalidURL(RequestException, ValueError): + """Exception for invalid URLs.""" + + def __init__( + self, + message: str, + response: FastlyResponse | None = None, + request: http_req.Request | None = None, + ) -> None: + """Initialize InvalidURL.""" + super().__init__(message, response, request) + + +class MissingSchema(RequestException, ValueError): + """Exception for URLs missing a schema (http://, https://, etc.).""" + + def __init__( + self, + message: str, + response: FastlyResponse | None = None, + request: http_req.Request | None = None, + ) -> None: + """Initialize MissingSchema.""" + super().__init__(message, response, request) + + +class InvalidHeader(RequestException, ValueError): + """Exception for invalid headers.""" + + def __init__( + self, + message: str, + response: FastlyResponse | None = None, + request: http_req.Request | None = None, + ) -> None: + """Initialize InvalidHeader.""" + super().__init__(message, response, request) + + +class ChunkedEncodingError(RequestException): + """Exception for chunked encoding errors.""" + + +class ContentDecodingError(RequestException): + """Exception for content decoding errors.""" + + +class StreamConsumedError(RequestException, TypeError): + """Exception for attempting to read a consumed stream.""" + + def __init__( + self, + message: str, + response: FastlyResponse | None = None, + request: http_req.Request | None = None, + ) -> None: + """Initialize StreamConsumedError.""" + super().__init__(message, response, request) + + +# WIT error detail mappings for http_req errors +WIT_SEND_ERROR_DETAIL_MAPPING: MappingProxyType[ + type[SendErrorDetail], type[RequestException] +] = MappingProxyType( + { + # Timeout errors + http_req.SendErrorDetail_DnsTimeout: Timeout, + http_req.SendErrorDetail_ConnectionTimeout: Timeout, + http_req.SendErrorDetail_HttpResponseTimeout: Timeout, + # Connection errors + http_req.SendErrorDetail_ConnectionRefused: ConnectionError, + http_req.SendErrorDetail_ConnectionTerminated: ConnectionError, + http_req.SendErrorDetail_DestinationNotFound: ConnectionError, + http_req.SendErrorDetail_DestinationUnavailable: ConnectionError, + http_req.SendErrorDetail_DestinationIpUnroutable: ConnectionError, + http_req.SendErrorDetail_DnsError: ConnectionError, + http_req.SendErrorDetail_TlsCertificateError: ConnectionError, + http_req.SendErrorDetail_TlsConfigurationError: ConnectionError, + http_req.SendErrorDetail_TlsAlertReceived: ConnectionError, + http_req.SendErrorDetail_TlsProtocolError: ConnectionError, + http_req.SendErrorDetail_ConnectionLimitReached: ConnectionError, + # HTTP protocol errors + http_req.SendErrorDetail_HttpIncompleteResponse: HTTPError, + http_req.SendErrorDetail_HttpResponseHeaderSectionTooLarge: HTTPError, + http_req.SendErrorDetail_HttpResponseBodyTooLarge: HTTPError, + http_req.SendErrorDetail_HttpUpgradeFailed: HTTPError, + http_req.SendErrorDetail_HttpProtocolError: HTTPError, + http_req.SendErrorDetail_HttpResponseStatusInvalid: HTTPError, + # Request/backend errors (default to RequestException) + http_req.SendErrorDetail_HttpRequestCacheKeyInvalid: RequestException, + http_req.SendErrorDetail_HttpRequestUriInvalid: RequestException, + http_req.SendErrorDetail_InternalError: RequestException, + } +) + +# WIT base error type mappings for generic errors +WIT_ERROR_MAPPINGS: MappingProxyType[type[wit_types.Error], type[RequestException]] = ( + MappingProxyType( + { + wit_types.Error_HttpInvalid: HTTPError, + wit_types.Error_HttpUser: HTTPError, + wit_types.Error_HttpIncomplete: HTTPError, + wit_types.Error_HttpHeadTooLarge: HTTPError, + wit_types.Error_HttpInvalidStatus: HTTPError, + wit_types.Error_CannotRead: ConnectionError, + } + ) +) diff --git a/fastly_compute/requests/response.py b/fastly_compute/requests/response.py new file mode 100644 index 0000000..17c1c27 --- /dev/null +++ b/fastly_compute/requests/response.py @@ -0,0 +1,195 @@ +"""A requests-compatible response object for Fastly Compute.""" + +import json +from http import HTTPStatus +from typing import Any, override + +from wit_world.imports import async_io, http_resp + +from ..utils import read_response_body +from .exceptions import HTTPError + + +class FastlyResponse: + """A requests.Response-compatible response object. + + This class wraps WIT response objects to provide the familiar + requests.Response interface. + """ + + def __init__( + self, + wit_response: http_resp.Response, + response_body: async_io.Pollable, + url: str, + ): + """Initialize FastlyResponse. + + Args: + wit_response: The WIT response object + response_body: The WIT response body + url: The final URL that was requested + """ + self._wit_response: http_resp.Response = wit_response + self._response_body: async_io.Pollable = response_body + self._url: str = url + self._content: bytes | None = None + self._text: str | None = None + self._headers: dict[str, str] | None = None + self._json_data: Any | None = None + + @property + def status_code(self) -> int: + """HTTP status code.""" + return self._wit_response.get_status() + + @property + def url(self) -> str: + """Final URL that was requested.""" + return self._url + + @property + def headers(self) -> dict[str, str]: + """Response headers as a case-insensitive dict.""" + if self._headers is None: + self._headers = {} + cursor = 0 + + # Read all headers using WIT API + while True: + try: + header_names, next_cursor = self._wit_response.get_header_names( + 4096, cursor + ) + if not header_names: + break + + # Split header names (they're null-separated) + names = header_names.split("\0")[:-1] # Remove empty last element + + for name in names: + if name: # Skip empty names + try: + value = self._wit_response.get_header_value(name, 4096) + if value: + # Convert to string and store with lowercase key for case-insensitive access + self._headers[name.lower()] = value.decode( + "utf-8", errors="replace" + ) + except Exception: + # Skip headers that can't be read + pass + + if not next_cursor: + break + cursor = next_cursor + + except Exception: + # If header reading fails, break out of loop + break + + return self._headers + + @property + def content(self) -> bytes: + """Response body as bytes.""" + if self._content is None: + self._content = self._read_body() + return self._content + + @property + def text(self) -> str: + """Response body as unicode string.""" + if self._text is None: + content = self.content + + # Try to determine encoding from headers + encoding = self._parse_charset() or "utf-8" + + try: + self._text = content.decode(encoding) + except UnicodeDecodeError: + # Fallback to utf-8 with error replacement + self._text = content.decode("utf-8", errors="replace") + + return self._text + + def json(self, **kwargs: Any) -> Any: + """Parse response body as JSON. + + Args: + **kwargs: Additional arguments passed to json.loads() + + Returns: + Parsed JSON data + + Raises: + json.JSONDecodeError: If response is not valid JSON + """ + if self._json_data is None: + self._json_data = json.loads(self.text, **kwargs) + return self._json_data + + @property + def ok(self) -> bool: + """True if status code is less than 400.""" + return 200 <= self.status_code < 400 + + @property + def is_redirect(self) -> bool: + """True if status code is a redirect (3xx).""" + return 300 <= self.status_code < 400 + + @property + def is_permanent_redirect(self) -> bool: + """True if status code is a permanent redirect.""" + return self.status_code in (301, 308) + + def raise_for_status(self) -> None: + """Raise an HTTPError for bad responses. + + Raises: + HTTPError: If response status indicates an error + """ + if not self.ok: + raise HTTPError( + f"{self.status_code} Client Error: {self.reason} for url: {self.url}", + response=self, + ) + + @property + def reason(self) -> str: + """HTTP status reason phrase.""" + try: + return HTTPStatus(self.status_code).phrase + except ValueError: + # Status code not in HTTPStatus enum + return "Unknown" + + def _parse_charset(self) -> str | None: + """Parse charset from Content-Type header.""" + content_type = self.headers.get("content-type", "") + if "charset=" in content_type: + try: + return content_type.split("charset=")[1].split(";")[0].strip() + except (IndexError, ValueError): + pass + return None + + @property + def encoding(self) -> str | None: + """Response encoding.""" + return self._parse_charset() + + def _read_body(self) -> bytes: + """Read the complete response body from WIT.""" + return read_response_body(self._response_body) + + def __bool__(self) -> bool: + """Boolean evaluation returns ok status.""" + return self.ok + + @override + def __repr__(self) -> str: + """String representation of the response.""" + return f"" diff --git a/fastly_compute/requests/timeout.py b/fastly_compute/requests/timeout.py new file mode 100644 index 0000000..4e06421 --- /dev/null +++ b/fastly_compute/requests/timeout.py @@ -0,0 +1,91 @@ +"""Timeout configuration for Fastly Compute requests. + +This module provides timeout configuration classes that support both standard +requests-compatible timeouts and Fastly-specific granular timeout controls. +""" + +from typing import override + + +class TimeoutConfig: + """Timeout configuration for Fastly backend requests. + + Fastly supports three distinct timeout phases: + - connect_timeout: Time to establish the initial TCP connection + - first_byte_timeout: Time between sending request and receiving first response byte + - between_bytes_timeout: Maximum time between any two consecutive bytes in response + + This provides much more granular control than the standard requests library, + which only supports a single timeout or (connect, read) tuple. + """ + + def __init__( + self, + connect: float = 30.0, + first_byte: float = 60.0, + between_bytes: float = 10.0, + ): + """Initialize timeout configuration. + + Args: + connect: Connection timeout in seconds (default: 30.0) + first_byte: First byte timeout in seconds (default: 60.0) + between_bytes: Between bytes timeout in seconds (default: 10.0) + """ + self.connect: float = connect + self.first_byte: float = first_byte + self.between_bytes: float = between_bytes + + @property + def connect_ms(self) -> int: + """Connection timeout in milliseconds (for WIT API).""" + return int(self.connect * 1000) + + @property + def first_byte_ms(self) -> int: + """First byte timeout in milliseconds (for WIT API).""" + return int(self.first_byte * 1000) + + @property + def between_bytes_ms(self) -> int: + """Between bytes timeout in milliseconds (for WIT API).""" + return int(self.between_bytes * 1000) + + @classmethod + def from_requests_timeout( + cls, timeout: None | float | tuple[float, float] + ) -> "TimeoutConfig": + """Create TimeoutConfig from requests-compatible timeout parameter. + + Args: + timeout: Timeout specification in requests-compatible formats: + - None: Use default timeouts + - float: Single timeout applied to all phases + - (connect, read): Tuple with separate connect and read timeouts + + Returns: + TimeoutConfig object with appropriate timeout values + + Raises: + ValueError: If timeout format is invalid + """ + if timeout is None: + return cls() + elif isinstance(timeout, int | float): + # Single timeout - use for all phases + return cls(connect=timeout, first_byte=timeout, between_bytes=timeout) + elif isinstance(timeout, tuple) and len(timeout) == 2: + # (connect, read) - requests-compatible format + connect, read = timeout + # Split read timeout between first_byte and between_bytes + # Use read timeout for first_byte, and half for between_bytes + return cls(connect=connect, first_byte=read, between_bytes=read / 2) + else: + raise ValueError( + f"Invalid timeout format: {timeout}. Expected None, float, or 2-tuple." + ) + + @override + def __repr__(self) -> str: + """Return string representation of TimeoutConfig.""" + return f"TimeoutConfig(connect={self.connect}, first_byte={self.first_byte}, between_bytes={self.between_bytes})" diff --git a/fastly_compute/wsgi.py b/fastly_compute/wsgi.py index 81040b1..ba6e294 100644 --- a/fastly_compute/wsgi.py +++ b/fastly_compute/wsgi.py @@ -196,6 +196,12 @@ def __init__( self.reuse_sandboxes_for_ms = reuse_sandboxes_for_ms def __call__(self): + """Return self to make the instance callable. + + This method makes the instance callable, which is required by the WSGI + specification. WSGI expects the application to be a callable that returns + itself when invoked without arguments. + """ return self def handle(self, request: Any, body: Any) -> None: diff --git a/main.py b/main.py deleted file mode 100644 index af8030a..0000000 --- a/main.py +++ /dev/null @@ -1,6 +0,0 @@ -def main(): - print("Hello from compute-sdk-python!") - - -if __name__ == "__main__": - main() diff --git a/test.toml b/test.toml new file mode 100644 index 0000000..27c472a --- /dev/null +++ b/test.toml @@ -0,0 +1,10 @@ +# Fastly service configuration +name = "my-compute-service" +description = "Example Fastly Compute service" +authors = ["paul.osborne@fastly.com"] +language = "python" + +[local_server] +[local_server.backends] +[local_server.backends.httpbin] +url = "http://httpbin.org/" diff --git a/tests/__snapshots__/test_backend_requests.ambr b/tests/__snapshots__/test_backend_requests.ambr new file mode 100644 index 0000000..0bc6155 --- /dev/null +++ b/tests/__snapshots__/test_backend_requests.ambr @@ -0,0 +1,76 @@ +# serializer version: 1 +# name: TestRequestsSimple.test_dynamic_get_no_url + dict({ + 'error': 'url query parameter is required', + }) +# --- +# name: TestRequestsSimple.test_dynamic_get_request + dict({ + 'backend': None, + 'content_length': 0, + 'headers_count': 5, + 'method': 'get', + 'response_preview': '', + 'status_code': 200, + 'success': True, + 'url': 'https://http-me.fastly.dev/get', + }) +# --- +# name: TestRequestsSimple.test_dynamic_post_request + dict({ + 'backend': None, + 'content_length': 0, + 'headers_count': 5, + 'method': 'post', + 'response_preview': '', + 'status_code': 200, + 'success': True, + 'url': 'https://http-me.fastly.dev/post', + }) +# --- +# name: TestRequestsSimple.test_static_get_request + dict({ + 'backend': 'test-be', + 'content_length': 363, + 'headers_count': 3, + 'method': 'get', + 'response_preview': ''' + { + "args": {}, + "headers": { + "host": "http-me.fastly.dev", + "content-type": "", + "connection": "keep-alive", + "user-agent": "python-requests/2.32.5", + "accept-encoding": "gzip, defla... + ''', + 'status_code': 200, + 'success': True, + 'url': 'https://http-me.fastly.dev/json', + }) +# --- +# name: TestRequestsSimple.test_static_post_request + dict({ + 'backend': 'test-be', + 'content_length': 259, + 'headers_count': 3, + 'method': 'post', + 'response_preview': ''' + { + "args": {}, + "data": "", + "files": {}, + "form": {}, + "headers": { + "User-Agent": "FastlyCompute-Requests/1.0", + "Content-Type": "application/json" + }, + "json": {}, + "method": "POST", + ... + ''', + 'status_code': 200, + 'success': True, + 'url': 'https://http-me.fastly.dev/post', + }) +# --- diff --git a/tests/test_backend_requests.py b/tests/test_backend_requests.py new file mode 100644 index 0000000..ee6d97d --- /dev/null +++ b/tests/test_backend_requests.py @@ -0,0 +1,625 @@ +"""Tests for the backend-requests example application.""" + +import sys +from typing import Any + +import pytest +import requests + +from fastly_compute.test_server import LocalTestServer +from fastly_compute.testing import ViceroyTestBase + + +class BackendRequestsTestBase(ViceroyTestBase): + """Base class for backend-requests tests with shared helper methods.""" + + WASM_FILE = "build/backend-requests.composed.wasm" + + def assert_success(self, response: requests.Response) -> dict[str, Any]: + """Assert that a result represents a successful operation. + + Args: + result: Response JSON dictionary + expected_demo: Expected value of 'demo' field + """ + # request to helper service succeeded (non-proxy) + assert response.status_code == 200 + + # make basic assertions on the proxied response + result: dict[str, Any] = response.json() + error = result.get("error") + success = result.get("success") + status_code = result.get("status_code") + + if error is not None or success is not True or status_code != 200: + tb = result.get("tb", "") + print(f"Error Detected {error}", file=sys.stderr) + print(f"Traceback: {tb}", file=sys.stderr) + raise AssertionError(f"Expected Success Response, got {error}") + + return result + + def assert_error( + self, response: requests.Response, error_substring: str | None = None + ) -> dict[str, Any]: + """Assert that a result represents an error. + + The return dictionary is the transformed error, with the traceback + information removed (which is too noisy to compare against). + + Args: + result: Response JSON dictionary + expected_demo: Expected value of 'demo' field + error_substring: Optional substring that should appear in error message + """ + result: dict[str, Any] = response.json() + error = result.get("error") + success = result.get("success") + status_code = result.get("status_code") + if not error or success or status_code == 200: + raise AssertionError(f"Unexpected Success, got {result}") + + if error_substring: + assert error_substring in result.get("error", ""), ( + f"Expected error to contain '{error_substring}', got: {result.get('error')}" + ) + + # strip out traceback info + _ = result.pop("tb", None) + return result + + +class TestRequestsSimple(BackendRequestsTestBase): + """Integration tests for the backend-requests example.""" + + @classmethod + def setup_class(cls): + """Set up local test server for httpbin-esque backend.""" + # Create httpbin-like responses + mock_responses = { + "/get": { + "status": 200, + "headers": {"Content-Type": "application/json"}, + "body": { + "args": {}, + "headers": { + "User-Agent": "FastlyCompute-Requests/1.0", + "Host": "localhost", + }, + "method": "GET", + "origin": "127.0.0.1", + "url": "http://localhost/get", + }, + }, + "/post": { + "status": 200, + "headers": {"Content-Type": "application/json"}, + "body": { + "args": {}, + "data": "", + "files": {}, + "form": {}, + "headers": { + "User-Agent": "FastlyCompute-Requests/1.0", + "Content-Type": "application/json", + }, + "json": {}, # Will be populated with actual request data + "method": "POST", + "origin": "127.0.0.1", + "url": "http://localhost/post", + }, + }, + } + + # Set up mock server + cls.test_server = LocalTestServer( + host="127.0.0.1", port=0, responses=mock_responses + ) + cls.test_server_url = cls.test_server.start() + + # Configure test-be backend for static backend tests + cls.set_up_backends({"test-be": cls.test_server_url}) + + @classmethod + def teardown_class(cls): + cls.test_server.stop() + + def test_static_get_request(self, snapshot): + response = self.get( + "/proxy/get", + params={"url": "https://http-me.fastly.dev/json", "backend": "test-be"}, + ) + data = self.assert_success(response) + assert data == snapshot + + def test_static_post_request(self, snapshot): + response = self.get( + "/proxy/post", + params={ + "url": "https://http-me.fastly.dev/post", + "backend": "test-be", + "json": '{"message": "Hello from Fastly Compute!", "demo": "static-post"}', + }, + ) + data = self.assert_success(response) + assert data == snapshot + + def test_dynamic_get_request(self, snapshot): + response = self.get( + "/proxy/get", + params={"url": "https://http-me.fastly.dev/get"}, + ) + data = self.assert_success(response) + assert data == snapshot + + def test_dynamic_get_no_url(self, snapshot): + """Test GET request with missing url parameter.""" + response = self.get("/proxy/get") + # Should get JSON error response + assert response.status_code == 200 + data = response.json() + assert "error" in data + assert "url query parameter is required" in data["error"] + assert data == snapshot + + def test_dynamic_post_request(self, snapshot): + response = self.get( + "/proxy/post", + params={ + "url": "https://http-me.fastly.dev/post", + "json": '{"service": "fastly-compute", "demo": "dynamic-post", "message": "Dynamic backend POST from Fastly Compute"}', + }, + ) + data = self.assert_success(response) + assert data == snapshot + + def test_invalid_url(self): + response = self.get("/proxy/get", params={"url": ".* not a valid url ~~~~"}) + _result = self.assert_error(response, "No scheme supplied") + + def test_invalid_backend(self): + response = self.get( + "/proxy/get", + params={"url": "http://http-me.fastly.dev", "backend": "does-not-exist"}, + ) + _result = self.assert_error( + response, + "Backend resolution failed: Static backend 'does-not-exist' does not exist", + ) + + +class TestRequestsCompatibility(BackendRequestsTestBase): + """Test that Fastly Compute requests behaves identically to standard requests. + + These tests verify that the Fastly Compute requests implementation produces + the same results as the standard Python requests library for common scenarios. + """ + + @classmethod + def setup_class(cls): + """Set up test backend with known responses.""" + # Configure test server with specific responses for compatibility testing + responses = { + "/json": { + "status": 200, + "headers": {"Content-Type": "application/json"}, + "body": {"message": "Hello", "count": 42}, + }, + "/text": { + "status": 200, + "headers": {"Content-Type": "text/plain"}, + "body": "Plain text response", + }, + "/headers": { + "status": 200, + "headers": { + "X-Custom-Header": "custom-value", + "Content-Type": "text/plain", + }, + "body": "Response with headers", + }, + "/status/404": { + "status": 404, + "headers": {"Content-Type": "text/plain"}, + "body": "Not Found", + }, + } + + cls.test_server = LocalTestServer(host="127.0.0.1", port=0, responses=responses) + cls.test_server_url = cls.test_server.start() + + # Set up backends for viceroy + cls.set_up_backends({"test-be": cls.test_server_url}) + + @classmethod + def teardown_class(cls): + """Clean up test server.""" + if hasattr(cls, "test_server"): + cls.test_server.stop() + + def test_json_response_compatibility(self): + """Test that JSON responses are handled identically.""" + # Make request through standard requests library + direct_response = requests.get(f"{self.test_server_url}/json") + + # Make request through Fastly Compute proxy + # Note: Must use full URL with static backend due to Viceroy limitation + proxy_response = self.get( + "/proxy/get", + params={ + "url": f"{self.test_server_url}/json", + "backend": "test-be", + }, + ) + + # Use helper to verify success and compare status codes + proxy_data = self.assert_success(proxy_response) + assert proxy_data["status_code"] == direct_response.status_code == 200 + + def test_text_response_compatibility(self): + """Test that text responses are handled identically.""" + # Make request through standard requests library + direct_response = requests.get(f"{self.test_server_url}/text") + + # Make request through Fastly Compute proxy + proxy_response = self.get( + "/proxy/get", + params={ + "url": f"{self.test_server_url}/text", + "backend": "test-be", + }, + ) + + # Use helper to verify success and compare content + proxy_data = self.assert_success(proxy_response) + assert proxy_data["status_code"] == direct_response.status_code + assert proxy_data["response_preview"] == direct_response.text + + def test_headers_compatibility(self): + """Test that response headers are handled identically.""" + # Make request through standard requests library + direct_response = requests.get(f"{self.test_server_url}/headers") + + # Make request through Fastly Compute proxy + proxy_response = self.get( + "/proxy/get", + params={ + "url": f"{self.test_server_url}/headers", + "backend": "test-be", + }, + ) + + # Use helper to verify success and compare headers + proxy_data = self.assert_success(proxy_response) + assert proxy_data["status_code"] == direct_response.status_code + assert proxy_data["headers_count"] > 0 + + def test_status_code_compatibility(self): + """Test that non-200 status codes are handled identically.""" + # Make request through standard requests library + direct_response = requests.get(f"{self.test_server_url}/status/404") + + # Make request through Fastly Compute proxy + proxy_response = self.get( + "/proxy/get", + params={ + "url": f"{self.test_server_url}/status/404", + "backend": "test-be", + }, + ) + + # 404 is not an error from the proxy's perspective (request succeeded), + # but success=False because response.ok is False for 404 + assert proxy_response.status_code == 200 + proxy_data = proxy_response.json() + + # Verify the proxy call succeeded without exceptions + assert "error" not in proxy_data + assert proxy_data["success"] is False # 404 is not "ok" + assert proxy_data["status_code"] == direct_response.status_code == 404 + assert proxy_data["response_preview"] == direct_response.text + + @pytest.mark.xfail( + reason="Viceroy doesn't populate error detail field - raises RequestException instead of ConnectionError" + ) + def test_connection_error_exception_type(self): + """Test that connection errors raise exceptions. + + Note: The standard requests library raises ConnectionError for connection + issues. Our facade should raise ConnectionError, but currently raises + RequestException due to Viceroy not populating the error detail field. + """ + # Make request through standard requests library to a port that doesn't exist + direct_exception_type = None + try: + requests.get("http://127.0.0.1:9", timeout=1) + except requests.exceptions.ConnectionError: + direct_exception_type = "ConnectionError" + except requests.exceptions.RequestException: + direct_exception_type = "RequestException" + + # Make request through Fastly Compute proxy + proxy_response = self.get( + "/proxy/get", + params={"url": "http://127.0.0.1:9"}, + ) + + # Verify the proxy returns an error + assert proxy_response.status_code == 200 + proxy_data = proxy_response.json() + assert "error" in proxy_data + # Should raise ConnectionError like requests library + assert proxy_data.get("error_type") == "ConnectionError" + + # Document what the standard library does for reference + assert direct_exception_type == "ConnectionError" + + def test_invalid_url_exception_type(self): + """Test that invalid URLs raise exceptions. + + Note: The standard requests library raises MissingSchema for URLs without + a scheme. Our facade now correctly raises MissingSchema to match. + """ + # Make request through standard requests library with invalid URL + direct_exception_type = None + try: + requests.get("not-a-valid-url") + except requests.exceptions.InvalidURL: + direct_exception_type = "InvalidURL" + except requests.exceptions.MissingSchema: + direct_exception_type = "MissingSchema" + except ValueError: + direct_exception_type = "ValueError" + + # Make request through Fastly Compute proxy + proxy_response = self.get( + "/proxy/get", + params={"url": "not-a-valid-url"}, + ) + + # Verify the proxy returns an error + assert proxy_response.status_code == 200 + proxy_data = proxy_response.json() + assert "error" in proxy_data + # Should raise MissingSchema like requests library + assert proxy_data.get("error_type") == "MissingSchema" + + # Document what the standard library does for reference + assert direct_exception_type in ["MissingSchema", "InvalidURL"] + + +class TestRequestErrorHandling(BackendRequestsTestBase): + """Test error handling in the requests module.""" + + @classmethod + def setup_class(cls): + """Set up test backend.""" + # Create a local test server for backend testing + cls.test_server = LocalTestServer(host="127.0.0.1", port=0) + base_url = cls.test_server.start() + + # Set up backends for viceroy (keep the full URL with scheme) + cls.set_up_backends({"test-be": base_url}) + + @classmethod + def teardown_class(cls): + """Clean up test server.""" + if hasattr(cls, "test_server"): + cls.test_server.stop() + + def test_invalid_url_for_dynamic_backend(self): + """Test that invalid URLs for dynamic backends return proper errors.""" + # Missing scheme - should raise MissingSchema like requests library + response = self.get("/proxy/get?url=just-a-path") + result = self.assert_error(response, "No scheme supplied") + assert result["error_type"] == "MissingSchema" + + def test_missing_url_parameter(self): + """Test that missing URL parameter returns proper error.""" + response = self.get("/proxy/get") + self.assert_error(response, "url query parameter is required") + + def test_invalid_json_parameter(self): + """Test that invalid JSON in query parameter returns proper error. + + Note: This error is caught at the proxy level during parameter validation, + so it doesn't include an error_type field from the requests library. + """ + response = self.get("/proxy/post?url=http://example.com&json=not-valid-json") + self.assert_error(response, "Invalid JSON") + + def test_both_data_and_json_parameters(self): + """Test that specifying both data and json parameters raises ValueError. + + This tests the validation in requests.request() that prevents + conflicting parameters. + """ + # This would need to be tested at the library level, not through the proxy + # endpoint since the proxy only supports json parameter. + # We'll add a note that this is validated at the API level. + pass + + @pytest.mark.xfail( + reason="Viceroy doesn't populate error detail field - raises RequestException instead of ConnectionError" + ) + def test_dynamic_backend_connection_refused(self): + """Test handling of connection refused errors. + + Try to connect to a port that's not listening to trigger + connection refused error from http_req.send(). + """ + # Use a port that's unlikely to be in use + response = self.get("/proxy/get?url=http://127.0.0.1:9") + assert response.status_code == 200 + data = response.json() + assert "error" in data + # Should raise ConnectionError like requests library + assert data.get("error_type") == "ConnectionError" + + @pytest.mark.xfail( + reason="Viceroy doesn't populate error detail field - raises RequestException instead of ConnectionError" + ) + def test_dynamic_backend_dns_failure(self): + """Test handling of DNS resolution failures. + + Use a hostname that doesn't exist to trigger DNS error. + """ + response = self.get( + "/proxy/get?url=http://this-domain-should-not-exist-12345.invalid" + ) + assert response.status_code == 200 + data = response.json() + assert "error" in data + # Should raise ConnectionError like requests library (DNS failures are connection errors) + assert data.get("error_type") == "ConnectionError" + + def test_invalid_backend_name(self): + """Test that invalid static backend names return proper errors.""" + response = self.get( + "/proxy/get?url=http://example.com&backend=nonexistent-backend" + ) + result = self.assert_error(response, "does not exist") + # Should raise RequestException for backend resolution failures + assert result["error_type"] == "RequestException" + + def test_backend_with_special_characters_in_url(self): + """Test backend handling with special characters in URL.""" + # Test with URL that has unicode characters + response = self.get("/proxy/get?url=http://example.com/test%20path") + assert response.status_code == 200 + data = response.json() + # This might succeed or fail depending on viceroy, but should handle gracefully + assert "error" in data or "success" in data + + def test_timeout_configuration_validation(self): + """Test that timeout validation works. + + The TimeoutConfig class has validation that we should test. + This would require a separate unit test or direct API testing. + """ + # Note: This is more of a unit test, consider adding to a separate file + pass + + def test_backend_with_empty_response(self): + """Test handling of backend that returns empty/minimal response.""" + # Use the test backend which will return proper responses + # Note: path-only URLs with static backends cause Viceroy to panic + # at src/upstream.rs:280 with InvalidUri, so we use a full URL + response = self.get("/proxy/get?url=http://httpbin.org/get&backend=test-be") + assert response.status_code == 200 + data = response.json() + # Should handle response gracefully + assert "success" in data or "error" in data + + def test_unicode_in_headers(self): + """Test that unicode characters in headers are handled properly. + + This tests the UnicodeError exception handling in set_request_headers. + """ + # The proxy endpoint would need to support custom headers for this + # This is a limitation of the current test proxy design + pass + + def test_large_response_body(self): + """Test handling of large response bodies. + + This exercises the read_response_body utility which has error handling + for WIT errors during body reading. + """ + # Would need to configure test server to return large response + # and verify it's handled correctly + pass + + +class TestBackendResolution(BackendRequestsTestBase): + """Test backend resolution logic specifically.""" + + @classmethod + def setup_class(cls): + """Set up multiple backends for testing.""" + cls.test_server1 = LocalTestServer(host="127.0.0.1", port=0) + base_url1 = cls.test_server1.start() + + cls.set_up_backends( + { + "test-be-1": base_url1, + } + ) + + @classmethod + def teardown_class(cls): + """Clean up test servers.""" + if hasattr(cls, "test_server1"): + cls.test_server1.stop() + + def test_backend_name_sanitization(self): + """Test that backend names are properly sanitized from URLs. + + The _sanitize_backend_name function should handle dots, colons, etc. + """ + # Test a URL that will fail to connect but shouldn't crash + # Use localhost with a port that won't respond + response = self.get("/proxy/get?url=http://localhost:9999/path") + assert response.status_code == 200 + data = response.json() + # Should get an error but not crash + assert "error" in data or "success" in data + + def test_dynamic_backend_reuse(self): + """Test that dynamic backends are reused when appropriate. + + Making multiple requests to the same dynamic backend should reuse + the registered backend rather than creating a new one each time. + """ + # Make multiple requests to the same dynamic backend + for _ in range(3): + response = self.get("/proxy/get?url=http://example.com/test") + assert response.status_code == 200 + + def test_static_backend_with_path(self): + """Test static backend with a path in the URL.""" + # When using static backend, the URL can be just a path + # The test backend should handle this + response = self.get("/proxy/get?url=http://httpbin.org/get&backend=test-be-1") + assert response.status_code == 200 + data = response.json() + # Should either succeed or have an error, but not crash + assert "error" in data or "success" in data + + +class TestHTTPMethodHandling(BackendRequestsTestBase): + """Test different HTTP methods and their error cases.""" + + @classmethod + def setup_class(cls): + """Set up test backend.""" + cls.test_server = LocalTestServer(host="127.0.0.1", port=0) + base_url = cls.test_server.start() + cls.set_up_backends({"test-be": base_url}) + + @classmethod + def teardown_class(cls): + """Clean up test server.""" + if hasattr(cls, "test_server"): + cls.test_server.stop() + + def test_post_with_empty_body(self): + """Test POST request with empty body.""" + response = self.get("/proxy/post?url=http://example.com") + assert response.status_code == 200 + # Should handle empty body gracefully + + def test_post_with_json_body(self): + """Test POST request with JSON body.""" + json_data = '{"key": "value", "number": 42}' + response = self.get(f"/proxy/post?url=http://example.com&json={json_data}") + assert response.status_code == 200 + + def test_various_http_methods(self): + """Test various HTTP methods (PUT, DELETE, PATCH).""" + methods = ["put", "delete", "patch"] + for method in methods: + response = self.get(f"/proxy/{method}?url=http://example.com") + # The proxy should handle these methods + # Response will depend on whether the backend accepts them + assert response.status_code == 200 From c9aa1daa024ce70ca50641187875b47f9d18946b Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Fri, 9 Jan 2026 10:59:13 -0600 Subject: [PATCH 03/14] backend: simplify error handling in example Use traceback.format_exc() and remove redundant None defaults from dict.get() calls --- examples/backend-requests.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/examples/backend-requests.py b/examples/backend-requests.py index a97938c..3a35ab7 100644 --- a/examples/backend-requests.py +++ b/examples/backend-requests.py @@ -3,7 +3,6 @@ import json import traceback import typing -from io import StringIO from typing import Any from bottle import Bottle, request @@ -21,12 +20,10 @@ def _map_error(e: Exception): e: The exception that occurred demo: The demo/endpoint name for this error """ - exc_print = StringIO() - traceback.print_exc(file=exc_print) return { "error": repr(e), "error_type": type(e).__name__, - "tb": exc_print.getvalue(), + "tb": traceback.format_exc(), } @@ -39,7 +36,7 @@ def _proxy_request(method: str, url: str, **kwargs) -> dict[str, Any]: return { "method": method, "url": url, - "backend": kwargs.get("backend", None), + "backend": kwargs.get("backend"), "status_code": response.status_code, "success": response.ok, "headers_count": len(response.headers), @@ -67,8 +64,8 @@ def proxy(method: str): if not url: return {"error": "url query parameter is required"} - backend = query.get("backend", None) - json_param = query.get("json", None) + backend = query.get("backend") + json_param = query.get("json") # Reconstitute JSON parameter if present json_data = None From ec28ed605bb1e20e7490aa9a01ff7be9d12c4a43 Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Fri, 9 Jan 2026 11:09:22 -0600 Subject: [PATCH 04/14] backend: rename parameters to fastly_* namespace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING CHANGE: backend → fastly_backend, timeout_config → fastly_timeout Establishes clear naming convention for Fastly-specific parameters --- examples/backend-requests.py | 6 +-- fastly_compute/requests/__init__.py | 46 +++++++++---------- fastly_compute/requests/backend.py | 12 +++-- .../__snapshots__/test_backend_requests.ambr | 8 ++-- tests/test_backend_requests.py | 30 ++++++++---- 5 files changed, 57 insertions(+), 45 deletions(-) diff --git a/examples/backend-requests.py b/examples/backend-requests.py index 3a35ab7..2cb2200 100644 --- a/examples/backend-requests.py +++ b/examples/backend-requests.py @@ -36,7 +36,7 @@ def _proxy_request(method: str, url: str, **kwargs) -> dict[str, Any]: return { "method": method, "url": url, - "backend": kwargs.get("backend"), + "fastly_backend": kwargs.get("fastly_backend"), "status_code": response.status_code, "success": response.ok, "headers_count": len(response.headers), @@ -64,7 +64,7 @@ def proxy(method: str): if not url: return {"error": "url query parameter is required"} - backend = query.get("backend") + backend = query.get("fastly_backend") json_param = query.get("json") # Reconstitute JSON parameter if present @@ -81,7 +81,7 @@ def proxy(method: str): headers[k] = v return _proxy_request( - method, url=url, backend=backend, json=json_data, headers=headers + method, url=url, fastly_backend=backend, json=json_data, headers=headers ) diff --git a/fastly_compute/requests/__init__.py b/fastly_compute/requests/__init__.py index 70958cd..f1d405c 100644 --- a/fastly_compute/requests/__init__.py +++ b/fastly_compute/requests/__init__.py @@ -7,7 +7,7 @@ import fastly_compute.requests as requests # Static backend (pre-configured) - response = requests.get("/api/users", backend="api-backend") + response = requests.get("/api/users", fastly_backend="api-backend") # Dynamic backend (external URLs) response = requests.get("https://http-me.fastly.dev/get") @@ -27,18 +27,18 @@ ) response = requests.get( "https://api.example.com/data", - timeout_config=timeout_config + fastly_timeout=timeout_config ) # Backend-specific features response = requests.get( "/api/endpoint", - backend="my-backend" # Use specific static backend + fastly_backend="my-backend" # Use specific static backend ) Compatibility Notes: Most parameters are compatible with the standard requests library. - Fastly-specific parameters (timeout_config, backend) will cause TypeErrors + Fastly-specific parameters (fastly_timeout, fastly_backend) will cause TypeErrors if used with the standard requests library. Use the standard timeout parameter for cross-platform compatibility. """ @@ -71,9 +71,9 @@ class RequestKwargs(TypedDict, total=False): json: Any | None params: dict[str, Any] headers: dict[str, str] - backend: str + fastly_backend: str timeout: None | float | tuple[float, float] - timeout_config: TimeoutConfig + fastly_timeout: TimeoutConfig # Export main components for public API @@ -109,23 +109,23 @@ def get( url: URL for the request. Can be a path (for static backends) or full URL (for dynamic) params: Query parameters to append to the URL headers: HTTP headers to send with the request - backend: Static backend name (optional, will use dynamic backend if not provided) + fastly_backend: Static backend name (optional, will use dynamic backend if not provided) timeout: Request timeout in seconds (requests-compatible). Can be: - float: Single timeout for all phases - (connect, read): Tuple for connect and read timeouts - timeout_config: **Fastly-only** Advanced timeout configuration with granular control + fastly_timeout: **Fastly-only** Advanced timeout configuration with granular control over connect_timeout, first_byte_timeout, and between_bytes_timeout **kwargs: Additional arguments (for requests compatibility, ignored) Note: - The timeout_config parameter is Fastly-specific and will cause a TypeError + The fastly_timeout parameter is Fastly-specific and will cause a TypeError if used with the standard requests library. Use timeout for cross-platform compatibility. Raises: RequestException: For general request errors ConnectionError: For connection-related errors Timeout: For timeout errors - ValueError: If both timeout and timeout_config are specified + ValueError: If both timeout and fastly_timeout are specified """ return request("GET", url, **kwargs) @@ -142,9 +142,9 @@ def post( json: JSON data to send in the body (mutually exclusive with data) params: Query parameters to append to the URL headers: HTTP headers to send with the request - backend: Static backend name (optional) + fastly_backend: Static backend name (optional) timeout: Request timeout in seconds (requests-compatible) - timeout_config: **Fastly-only** Advanced timeout configuration + fastly_timeout: **Fastly-only** Advanced timeout configuration **kwargs: Additional arguments (for requests compatibility, ignored) """ return request("POST", url, **kwargs) @@ -197,9 +197,9 @@ def request( data: str | bytes | dict[str, Any] | None = None, json: dict[str, Any] | None = None, headers: dict[str, str] | None = None, - backend: str | None = None, + fastly_backend: str | None = None, timeout: None | float | tuple[float, float] = None, - timeout_config: TimeoutConfig | None = None, + fastly_timeout: TimeoutConfig | None = None, **_kwargs: Any, ) -> FastlyResponse: """Send an HTTP request. @@ -211,9 +211,9 @@ def request( data: Form data for the request body json: JSON data for the request body (mutually exclusive with data) headers: HTTP headers - backend: Static backend name (if not provided, will use dynamic backend) + fastly_backend: Static backend name (if not provided, will use dynamic backend) timeout: Request timeout in seconds (requests-compatible) - timeout_config: **Fastly-only** Advanced timeout configuration + fastly_timeout: **Fastly-only** Advanced timeout configuration **kwargs: Additional arguments (for requests compatibility, ignored) Raises: @@ -224,19 +224,19 @@ def request( if data is not None and json is not None: raise ValueError("Cannot specify both 'data' and 'json' parameters") - if timeout is not None and timeout_config is not None: + if timeout is not None and fastly_timeout is not None: raise ValueError( - "Cannot specify both 'timeout' and 'timeout_config' parameters" + "Cannot specify both 'timeout' and 'fastly_timeout' parameters" ) # Resolve timeout configuration - if timeout_config is not None: - timeout_config = timeout_config + if fastly_timeout is not None: + fastly_timeout = fastly_timeout else: - timeout_config = TimeoutConfig.from_requests_timeout(timeout) + fastly_timeout = TimeoutConfig.from_requests_timeout(timeout) try: - resolution = resolve_backend(url, backend, timeout_config) + resolution = resolve_backend(url, fastly_backend, fastly_timeout) except RequestException: # Let RequestException subclasses (MissingSchema, etc.) pass through unchanged raise @@ -278,7 +278,7 @@ def request( # - The netloc for this request OR # - The netloc from the registered backend headers = headers if headers is not None else {} - if backend is not None: + if fastly_backend is not None: host_header = headers.pop("Host", url_parsed.netloc) wit_request.insert_header("Host", host_header.encode("utf-8")) diff --git a/fastly_compute/requests/backend.py b/fastly_compute/requests/backend.py index 19965cc..03185c9 100644 --- a/fastly_compute/requests/backend.py +++ b/fastly_compute/requests/backend.py @@ -33,14 +33,14 @@ class BackendResolution: def resolve_backend( url: str, - backend: str | None = None, + fastly_backend: str | None = None, timeout_config: TimeoutConfig | None = None, ) -> BackendResolution: """Resolve backend name and final URL for a request. Args: url: The URL to request (can be path-only or full URL) - backend: Optional static backend name + fastly_backend: Optional static backend name timeout_config: Optional timeout configuration for dynamic backends Returns: @@ -53,14 +53,16 @@ def resolve_backend( parsed = urllib.parse.urlparse(url) backend_obj: wit_backend.Backend - if backend is not None: + if fastly_backend is not None: # Check if backend exists by trying to open it try: - backend_obj = wit_backend.Backend.open(backend) + backend_obj = wit_backend.Backend.open(fastly_backend) except Err as e: # Check if this is an OpenError (backend not found) if isinstance(e.value, OpenError): - raise ValueError(f"Static backend '{backend}' does not exist") from e + raise ValueError( + f"Static backend '{fastly_backend}' does not exist" + ) from e # Re-raise if it's a different error raise else: diff --git a/tests/__snapshots__/test_backend_requests.ambr b/tests/__snapshots__/test_backend_requests.ambr index 0bc6155..02ee7c8 100644 --- a/tests/__snapshots__/test_backend_requests.ambr +++ b/tests/__snapshots__/test_backend_requests.ambr @@ -6,8 +6,8 @@ # --- # name: TestRequestsSimple.test_dynamic_get_request dict({ - 'backend': None, 'content_length': 0, + 'fastly_backend': None, 'headers_count': 5, 'method': 'get', 'response_preview': '', @@ -18,8 +18,8 @@ # --- # name: TestRequestsSimple.test_dynamic_post_request dict({ - 'backend': None, 'content_length': 0, + 'fastly_backend': None, 'headers_count': 5, 'method': 'post', 'response_preview': '', @@ -30,8 +30,8 @@ # --- # name: TestRequestsSimple.test_static_get_request dict({ - 'backend': 'test-be', 'content_length': 363, + 'fastly_backend': 'test-be', 'headers_count': 3, 'method': 'get', 'response_preview': ''' @@ -51,8 +51,8 @@ # --- # name: TestRequestsSimple.test_static_post_request dict({ - 'backend': 'test-be', 'content_length': 259, + 'fastly_backend': 'test-be', 'headers_count': 3, 'method': 'post', 'response_preview': ''' diff --git a/tests/test_backend_requests.py b/tests/test_backend_requests.py index ee6d97d..5a24b04 100644 --- a/tests/test_backend_requests.py +++ b/tests/test_backend_requests.py @@ -127,7 +127,10 @@ def teardown_class(cls): def test_static_get_request(self, snapshot): response = self.get( "/proxy/get", - params={"url": "https://http-me.fastly.dev/json", "backend": "test-be"}, + params={ + "url": "https://http-me.fastly.dev/json", + "fastly_backend": "test-be", + }, ) data = self.assert_success(response) assert data == snapshot @@ -137,7 +140,7 @@ def test_static_post_request(self, snapshot): "/proxy/post", params={ "url": "https://http-me.fastly.dev/post", - "backend": "test-be", + "fastly_backend": "test-be", "json": '{"message": "Hello from Fastly Compute!", "demo": "static-post"}', }, ) @@ -180,7 +183,10 @@ def test_invalid_url(self): def test_invalid_backend(self): response = self.get( "/proxy/get", - params={"url": "http://http-me.fastly.dev", "backend": "does-not-exist"}, + params={ + "url": "http://http-me.fastly.dev", + "fastly_backend": "does-not-exist", + }, ) _result = self.assert_error( response, @@ -248,7 +254,7 @@ def test_json_response_compatibility(self): "/proxy/get", params={ "url": f"{self.test_server_url}/json", - "backend": "test-be", + "fastly_backend": "test-be", }, ) @@ -266,7 +272,7 @@ def test_text_response_compatibility(self): "/proxy/get", params={ "url": f"{self.test_server_url}/text", - "backend": "test-be", + "fastly_backend": "test-be", }, ) @@ -285,7 +291,7 @@ def test_headers_compatibility(self): "/proxy/get", params={ "url": f"{self.test_server_url}/headers", - "backend": "test-be", + "fastly_backend": "test-be", }, ) @@ -304,7 +310,7 @@ def test_status_code_compatibility(self): "/proxy/get", params={ "url": f"{self.test_server_url}/status/404", - "backend": "test-be", + "fastly_backend": "test-be", }, ) @@ -476,7 +482,7 @@ def test_dynamic_backend_dns_failure(self): def test_invalid_backend_name(self): """Test that invalid static backend names return proper errors.""" response = self.get( - "/proxy/get?url=http://example.com&backend=nonexistent-backend" + "/proxy/get?url=http://example.com&fastly_backend=nonexistent-backend" ) result = self.assert_error(response, "does not exist") # Should raise RequestException for backend resolution failures @@ -505,7 +511,9 @@ def test_backend_with_empty_response(self): # Use the test backend which will return proper responses # Note: path-only URLs with static backends cause Viceroy to panic # at src/upstream.rs:280 with InvalidUri, so we use a full URL - response = self.get("/proxy/get?url=http://httpbin.org/get&backend=test-be") + response = self.get( + "/proxy/get?url=http://httpbin.org/get&fastly_backend=test-be" + ) assert response.status_code == 200 data = response.json() # Should handle response gracefully @@ -580,7 +588,9 @@ def test_static_backend_with_path(self): """Test static backend with a path in the URL.""" # When using static backend, the URL can be just a path # The test backend should handle this - response = self.get("/proxy/get?url=http://httpbin.org/get&backend=test-be-1") + response = self.get( + "/proxy/get?url=http://httpbin.org/get&fastly_backend=test-be-1" + ) assert response.status_code == 200 data = response.json() # Should either succeed or have an error, but not crash From de5850e2471cff864bf0b3daf1ab31d86b03aebb Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Fri, 9 Jan 2026 11:15:18 -0600 Subject: [PATCH 05/14] backend: remove dead code from request() Remove no-op assignment, simplify exception handling, and remove redundant checks --- fastly_compute/requests/__init__.py | 19 ++++--------------- fastly_compute/requests/backend.py | 4 ++-- tests/test_backend_requests.py | 2 +- 3 files changed, 7 insertions(+), 18 deletions(-) diff --git a/fastly_compute/requests/__init__.py b/fastly_compute/requests/__init__.py index f1d405c..089cf64 100644 --- a/fastly_compute/requests/__init__.py +++ b/fastly_compute/requests/__init__.py @@ -230,20 +230,10 @@ def request( ) # Resolve timeout configuration - if fastly_timeout is not None: - fastly_timeout = fastly_timeout - else: + if fastly_timeout is None: fastly_timeout = TimeoutConfig.from_requests_timeout(timeout) - try: - resolution = resolve_backend(url, fastly_backend, fastly_timeout) - except RequestException: - # Let RequestException subclasses (MissingSchema, etc.) pass through unchanged - raise - except ValueError as e: - # Other ValueError cases (e.g., backend not found) - raise RequestException(f"Backend resolution failed: {e}") from e - + resolution = resolve_backend(url, fastly_backend, fastly_timeout) url_parsed = resolution.url_parsed # Add query parameters if provided @@ -289,9 +279,8 @@ def request( wit_request.insert_header("User-Agent", b"FastlyCompute-Requests/1.0") # Add custom headers - if headers: - for name, value in headers.items(): - wit_request.insert_header(name, value.encode("utf-8")) + for name, value in headers.items(): + wit_request.insert_header(name, value.encode("utf-8")) except (ValueError, UnicodeError) as e: raise RequestException(f"Invalid headers: {e}") from e except Err as e: diff --git a/fastly_compute/requests/backend.py b/fastly_compute/requests/backend.py index 03185c9..d2f465b 100644 --- a/fastly_compute/requests/backend.py +++ b/fastly_compute/requests/backend.py @@ -48,7 +48,7 @@ def resolve_backend( Raises: RequestException: If backend resolution fails - ValueError: If inputs are malformed + MissingSchema: If URL is missing scheme (subclass of RequestException) """ parsed = urllib.parse.urlparse(url) @@ -60,7 +60,7 @@ def resolve_backend( except Err as e: # Check if this is an OpenError (backend not found) if isinstance(e.value, OpenError): - raise ValueError( + raise RequestException( f"Static backend '{fastly_backend}' does not exist" ) from e # Re-raise if it's a different error diff --git a/tests/test_backend_requests.py b/tests/test_backend_requests.py index 5a24b04..2b5ec14 100644 --- a/tests/test_backend_requests.py +++ b/tests/test_backend_requests.py @@ -190,7 +190,7 @@ def test_invalid_backend(self): ) _result = self.assert_error( response, - "Backend resolution failed: Static backend 'does-not-exist' does not exist", + "Static backend 'does-not-exist' does not exist", ) From 3a50a61462a81d690ea8bc041bcc784ef08f1270 Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Fri, 9 Jan 2026 11:17:12 -0600 Subject: [PATCH 06/14] backend: simplify json type annotation Remove redundant None from Any | None union --- fastly_compute/requests/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastly_compute/requests/__init__.py b/fastly_compute/requests/__init__.py index 089cf64..caa1193 100644 --- a/fastly_compute/requests/__init__.py +++ b/fastly_compute/requests/__init__.py @@ -68,7 +68,7 @@ class RequestKwargs(TypedDict, total=False): """Common keyword arguments for all request methods.""" data: str | bytes | dict[str, Any] | None - json: Any | None + json: Any params: dict[str, Any] headers: dict[str, str] fastly_backend: str From 903e3a94421d8485250b83765ec30446c13efc31 Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Fri, 9 Jan 2026 11:20:33 -0600 Subject: [PATCH 07/14] backend: extract common error mapping logic Reduce duplication in from_http_req_error() and from_wit_error() --- fastly_compute/requests/exceptions.py | 50 +++++++++++++++++++++------ 1 file changed, 39 insertions(+), 11 deletions(-) diff --git a/fastly_compute/requests/exceptions.py b/fastly_compute/requests/exceptions.py index 7d2ca47..81c65d7 100644 --- a/fastly_compute/requests/exceptions.py +++ b/fastly_compute/requests/exceptions.py @@ -16,6 +16,28 @@ from wit_world.imports.http_req import SendErrorDetail +def _map_error_to_exception( + error: object, + mapping: MappingProxyType, + operation: str, + fallback_cls: type[RequestException], +) -> RequestException: + """Map WIT error to appropriate RequestException subclass. + + Args: + error: The WIT error object to map + mapping: Mapping from error types to exception classes + operation: Description of operation that failed + fallback_cls: Exception class to use if no mapping found + + Returns: + Appropriate RequestException subclass instance + """ + error_type = type(error) + exc_cls = mapping.get(error_type, fallback_cls) + return exc_cls(f"{operation}: {error_type.__name__}") + + class RequestException(IOError): """Base exception for all requests-related errors.""" @@ -54,16 +76,20 @@ def from_http_req_error( # Try detailed error classification first; this is not guaranteed # to be present in all cases. if error_with_detail.detail is not None: - send_error_type = type(error_with_detail.detail) - requests_exc_type = WIT_SEND_ERROR_DETAIL_MAPPING.get(send_error_type, cls) - return requests_exc_type(f"{operation}: {send_error_type.__name__}") + return _map_error_to_exception( + error_with_detail.detail, + WIT_SEND_ERROR_DETAIL_MAPPING, + operation, + cls, + ) # No detailed error - classify based on base error type - base_error_type = type(error_with_detail.error) - requests_exc_type: type[RequestException] = WIT_ERROR_MAPPINGS.get( - base_error_type, cls + return _map_error_to_exception( + error_with_detail.error, + WIT_ERROR_MAPPINGS, + operation, + cls, ) - return requests_exc_type(f"{operation}: {base_error_type.__name__}") @classmethod def from_wit_error( @@ -78,10 +104,12 @@ def from_wit_error( Returns: Appropriate RequestException subclass instance """ - error_type = type(err.value) - exception_class = WIT_ERROR_MAPPINGS.get(error_type, cls) - message = f"Operation {operation} failed: {error_type.__name__}" - return exception_class(message) + return _map_error_to_exception( + err.value, + WIT_ERROR_MAPPINGS, + f"Operation {operation} failed", + cls, + ) class ConnectionError(RequestException): From 0147f179ef05e2e166e536e864221e2434559064 Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Fri, 9 Jan 2026 11:22:46 -0600 Subject: [PATCH 08/14] backend: remove boilerplate exception constructors Remove __init__ methods that only call super() with same signature --- fastly_compute/requests/exceptions.py | 51 --------------------------- 1 file changed, 51 deletions(-) diff --git a/fastly_compute/requests/exceptions.py b/fastly_compute/requests/exceptions.py index 81c65d7..0e774cc 100644 --- a/fastly_compute/requests/exceptions.py +++ b/fastly_compute/requests/exceptions.py @@ -123,21 +123,6 @@ class Timeout(RequestException): class HTTPError(RequestException): """Exception for HTTP error responses (4xx, 5xx status codes).""" - def __init__( - self, - message: str, - response: FastlyResponse | None = None, - request: http_req.Request | None = None, - ) -> None: - """Initialize HTTPError. - - Args: - message: Error message - response: Response object that caused the error - request: Request object that caused the error - """ - super().__init__(message, response, request) - class TooManyRedirects(RequestException): """Exception for too many redirects.""" @@ -146,41 +131,14 @@ class TooManyRedirects(RequestException): class InvalidURL(RequestException, ValueError): """Exception for invalid URLs.""" - def __init__( - self, - message: str, - response: FastlyResponse | None = None, - request: http_req.Request | None = None, - ) -> None: - """Initialize InvalidURL.""" - super().__init__(message, response, request) - class MissingSchema(RequestException, ValueError): """Exception for URLs missing a schema (http://, https://, etc.).""" - def __init__( - self, - message: str, - response: FastlyResponse | None = None, - request: http_req.Request | None = None, - ) -> None: - """Initialize MissingSchema.""" - super().__init__(message, response, request) - class InvalidHeader(RequestException, ValueError): """Exception for invalid headers.""" - def __init__( - self, - message: str, - response: FastlyResponse | None = None, - request: http_req.Request | None = None, - ) -> None: - """Initialize InvalidHeader.""" - super().__init__(message, response, request) - class ChunkedEncodingError(RequestException): """Exception for chunked encoding errors.""" @@ -193,15 +151,6 @@ class ContentDecodingError(RequestException): class StreamConsumedError(RequestException, TypeError): """Exception for attempting to read a consumed stream.""" - def __init__( - self, - message: str, - response: FastlyResponse | None = None, - request: http_req.Request | None = None, - ) -> None: - """Initialize StreamConsumedError.""" - super().__init__(message, response, request) - # WIT error detail mappings for http_req errors WIT_SEND_ERROR_DETAIL_MAPPING: MappingProxyType[ From ea68870cd11acf139418f78d397fc9067181f0e2 Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Fri, 9 Jan 2026 11:25:16 -0600 Subject: [PATCH 09/14] backend: remove overly broad exception handling Allow exceptions to propagate for proper error handling and debugging --- fastly_compute/requests/response.py | 49 ++++++++++++----------------- 1 file changed, 20 insertions(+), 29 deletions(-) diff --git a/fastly_compute/requests/response.py b/fastly_compute/requests/response.py index 17c1c27..4963c5d 100644 --- a/fastly_compute/requests/response.py +++ b/fastly_compute/requests/response.py @@ -57,37 +57,28 @@ def headers(self) -> dict[str, str]: # Read all headers using WIT API while True: - try: - header_names, next_cursor = self._wit_response.get_header_names( - 4096, cursor - ) - if not header_names: - break - - # Split header names (they're null-separated) - names = header_names.split("\0")[:-1] # Remove empty last element - - for name in names: - if name: # Skip empty names - try: - value = self._wit_response.get_header_value(name, 4096) - if value: - # Convert to string and store with lowercase key for case-insensitive access - self._headers[name.lower()] = value.decode( - "utf-8", errors="replace" - ) - except Exception: - # Skip headers that can't be read - pass - - if not next_cursor: - break - cursor = next_cursor - - except Exception: - # If header reading fails, break out of loop + header_names, next_cursor = self._wit_response.get_header_names( + 4096, cursor + ) + if not header_names: break + # Split header names (they're null-separated) + names = header_names.split("\0")[:-1] # Remove empty last element + + for name in names: + if name: # Skip empty names + value = self._wit_response.get_header_value(name, 4096) + if value: + # Convert to string and store with lowercase key for case-insensitive access + self._headers[name.lower()] = value.decode( + "utf-8", errors="replace" + ) + + if not next_cursor: + break + cursor = next_cursor + return self._headers @property From c9617a1e23a81c1193af3a4f1dbf194a9bf597de Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Fri, 9 Jan 2026 11:30:13 -0600 Subject: [PATCH 10/14] backend: cleanup test infrastructure Make test_server variables private and remove unnecessary hasattr check --- fastly_compute/test_server.py | 32 ++++++++++++++++---------------- tests/test_backend_requests.py | 1 + 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/fastly_compute/test_server.py b/fastly_compute/test_server.py index f6efc60..9d831e1 100644 --- a/fastly_compute/test_server.py +++ b/fastly_compute/test_server.py @@ -157,8 +157,8 @@ def __init__( self.host = host self.port = port self.responses = responses or {} - self.server: HTTPServer | None = None - self.thread: threading.Thread | None = None + self._server: HTTPServer | None = None + self._thread: threading.Thread | None = None def start(self) -> str: """Start the test server. @@ -166,23 +166,23 @@ def start(self) -> str: Returns: The base URL of the started server (e.g., "http://127.0.0.1:12345") """ - if self.server is not None: + if self._server is not None: raise RuntimeError("Server is already running") # Create a handler class with our responses configured handler_class = make_test_request_handler(self.responses) # Create server - self.server = HTTPServer((self.host, self.port), handler_class) + self._server = HTTPServer((self.host, self.port), handler_class) # Get actual port (important when port=0 for auto-assignment) # server_address returns (host, port) for IPv4, or (host, port, flowinfo, scopeid) for IPv6 - actual_port = self.server.server_address[1] # Port is always at index 1 + actual_port = self._server.server_address[1] # Port is always at index 1 base_url = f"http://{self.host}:{actual_port}" # Start server in background thread - self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) - self.thread.start() + self._thread = threading.Thread(target=self._server.serve_forever, daemon=True) + self._thread.start() # Wait a bit for server to be ready time.sleep(0.1) @@ -191,16 +191,16 @@ def start(self) -> str: def stop(self): """Stop the test server.""" - if self.server is None: + if self._server is None: return - self.server.shutdown() - self.server.server_close() - self.server = None + self._server.shutdown() + self._server.server_close() + self._server = None - if self.thread and self.thread.is_alive(): - self.thread.join(timeout=1.0) - self.thread = None + if self._thread and self._thread.is_alive(): + self._thread.join(timeout=1.0) + self._thread = None def __enter__(self): """Context manager entry.""" @@ -213,9 +213,9 @@ def __exit__(self, exc_type, exc_val, exc_tb): @property def base_url(self) -> str: """Get the base URL of the running server.""" - if self.server is None: + if self._server is None: raise RuntimeError("Server is not running") # Port is always at index 1 regardless of address family - port = self.server.server_address[1] + port = self._server.server_address[1] return f"http://{self.host}:{port}" diff --git a/tests/test_backend_requests.py b/tests/test_backend_requests.py index 2b5ec14..a146122 100644 --- a/tests/test_backend_requests.py +++ b/tests/test_backend_requests.py @@ -122,6 +122,7 @@ def setup_class(cls): @classmethod def teardown_class(cls): + """Clean up test server.""" cls.test_server.stop() def test_static_get_request(self, snapshot): From 57f09366d8dec49094243b9d3fe2a3fc6ffe2c48 Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Fri, 9 Jan 2026 11:52:57 -0600 Subject: [PATCH 11/14] backend: simplify dynamic backend naming Previously we were doing a sanitize step that I do not believe is really necessary. We now use the lowered netloc directly for the dynamic backend name. Additionaly, a case related to an empty netloc was removed as it is not possible to hit. --- fastly_compute/requests/backend.py | 43 ++++++++---------------------- 1 file changed, 11 insertions(+), 32 deletions(-) diff --git a/fastly_compute/requests/backend.py b/fastly_compute/requests/backend.py index d2f465b..9f8d4d5 100644 --- a/fastly_compute/requests/backend.py +++ b/fastly_compute/requests/backend.py @@ -38,8 +38,12 @@ def resolve_backend( ) -> BackendResolution: """Resolve backend name and final URL for a request. + If a `fastly_backend` is provided, we'll attempt to lookup that static + backend, otherwise a dynamic backend will be registered and/or + used based on the netloc of the url. + Args: - url: The URL to request (can be path-only or full URL) + url: The URL to request (must be full with scheme and netloc for dynamic backends) fastly_backend: Optional static backend name timeout_config: Optional timeout configuration for dynamic backends @@ -51,8 +55,9 @@ def resolve_backend( MissingSchema: If URL is missing scheme (subclass of RequestException) """ parsed = urllib.parse.urlparse(url) - backend_obj: wit_backend.Backend + + # static backend if fastly_backend is not None: # Check if backend exists by trying to open it try: @@ -66,14 +71,14 @@ def resolve_backend( # Re-raise if it's a different error raise else: + # dynamic backend if not parsed.scheme or not parsed.netloc: raise MissingSchema( - f"Invalid URL {url!r}: No scheme supplied. " - f"Perhaps you meant https://{url}?" + f"Invalid URL {url!r}: No scheme supplied. Perhaps you meant https://{url}?" ) # Register dynamic backend if not already registered - backend_name = _sanitize_backend_name(parsed) + backend_name = parsed.netloc.lower() timeout_config = timeout_config or TimeoutConfig() if backend_name not in _dynamic_backends: backend_obj = _register_dynamic_backend( @@ -81,14 +86,9 @@ def resolve_backend( ) _dynamic_backends.add(backend_name) else: - # Open the already-registered backend backend_obj = wit_backend.Backend.open(backend_name) - if parsed.netloc == "": - host = backend_obj.get_host(1024) - parsed = parsed._replace(netloc=host) - - return BackendResolution(url_parsed=parsed, backend=backend_obj) + return BackendResolution(parsed, backend_obj) def _register_dynamic_backend( @@ -116,24 +116,3 @@ def _register_dynamic_backend( ) except Err as e: raise RequestException.from_wit_error(e, "register_dynamic_backend") from e - - -def _sanitize_backend_name(parsed: urllib.parse.ParseResult) -> str: - # Replace dots, colons, and other special chars with underscores - # Keep only alphanumeric chars and underscores - sanitized = "" - for char in parsed.netloc.lower(): - if char.isalnum(): - sanitized += char - elif char in ".-:": - sanitized += "_" - - # Remove multiple consecutive underscores if present - while "__" in sanitized: - sanitized = sanitized.replace("__", "_") - - # Remove leading/trailing underscores - sanitized = sanitized.strip("_") - assert sanitized, "Generated an errant empty backend name!" - - return sanitized From a4200a57b506a07e0a9d25230472a51b104e5abd Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Fri, 9 Jan 2026 12:51:56 -0600 Subject: [PATCH 12/14] backend: polish code per review feedback - Flip ternary to avoid 'not' (clearer logic) - Remove redundant UnicodeError catch (subclass of ValueError) - Improve resolve_backend() docstring with clearer algorithm explanation --- fastly_compute/requests/__init__.py | 4 ++-- fastly_compute/requests/backend.py | 9 ++++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/fastly_compute/requests/__init__.py b/fastly_compute/requests/__init__.py index caa1193..d88ebce 100644 --- a/fastly_compute/requests/__init__.py +++ b/fastly_compute/requests/__init__.py @@ -281,7 +281,7 @@ def request( # Add custom headers for name, value in headers.items(): wit_request.insert_header(name, value.encode("utf-8")) - except (ValueError, UnicodeError) as e: + except ValueError as e: raise RequestException(f"Invalid headers: {e}") from e except Err as e: raise RequestException.from_wit_error(e, "set_request_headers") from e @@ -295,7 +295,7 @@ def request( try: if json is not None: # JSON data - use the json module, not the parameter - json_str = json_module.dumps(json) if not isinstance(json, str) else json + json_str = json if isinstance(json, str) else json_module.dumps(json) json_bytes = json_str.encode("utf-8") wit_request.insert_header("Content-Type", b"application/json") _http_body_write_all(request_body, json_bytes) diff --git a/fastly_compute/requests/backend.py b/fastly_compute/requests/backend.py index 9f8d4d5..70cb6d3 100644 --- a/fastly_compute/requests/backend.py +++ b/fastly_compute/requests/backend.py @@ -38,9 +38,12 @@ def resolve_backend( ) -> BackendResolution: """Resolve backend name and final URL for a request. - If a `fastly_backend` is provided, we'll attempt to lookup that static - backend, otherwise a dynamic backend will be registered and/or - used based on the netloc of the url. + This function determines which Fastly backend to use for a request: + - If `fastly_backend` is provided, attempts to open that pre-configured static backend + - Otherwise, registers/reuses a dynamic backend based on the URL's netloc + + Static backends must already be configured in the Fastly service. + Dynamic backends are automatically registered on first use and cached for reuse. Args: url: The URL to request (must be full with scheme and netloc for dynamic backends) From 0eac60b1003dd27bf14cd422df7ef5677951a11f Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Fri, 9 Jan 2026 12:58:57 -0600 Subject: [PATCH 13/14] docs: consolidate examples in module docstring Merge Fastly-specific features section into Basic Usage for better flow. The examples now show a progression from simple to advanced usage. --- fastly_compute/requests/__init__.py | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/fastly_compute/requests/__init__.py b/fastly_compute/requests/__init__.py index d88ebce..bccccd4 100644 --- a/fastly_compute/requests/__init__.py +++ b/fastly_compute/requests/__init__.py @@ -3,23 +3,23 @@ This module provides a familiar requests-like API while leveraging Fastly's backend architecture and WIT bindings for optimal performance. -Basic Usage: +Usage:: import fastly_compute.requests as requests + from fastly_compute.requests import TimeoutConfig # Static backend (pre-configured) response = requests.get("/api/users", fastly_backend="api-backend") + # or, providing full URL (will still use backend configuration) + response = requests.get("https://example.com/api/users", fastly_backend="api-backend") - # Dynamic backend (external URLs) + # Dynamic backend (looks like normal requests usage) response = requests.get("https://http-me.fastly.dev/get") # POST with JSON response = requests.post("https://http-me.fastly.dev/post", json={"key": "value"}) -Fastly-Specific Features: - from fastly_compute.requests import TimeoutConfig - - # Granular timeout control (not available in standard requests) + # Granular timeout control (Fastly-specific) timeout_config = TimeoutConfig( connect=5.0, # 5s to establish connection first_byte=30.0, # 30s to receive first byte @@ -30,12 +30,6 @@ fastly_timeout=timeout_config ) - # Backend-specific features - response = requests.get( - "/api/endpoint", - fastly_backend="my-backend" # Use specific static backend - ) - Compatibility Notes: Most parameters are compatible with the standard requests library. Fastly-specific parameters (fastly_timeout, fastly_backend) will cause TypeErrors From fdb713a0838fd431e54c7a601ed381463ba98aa0 Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Mon, 12 Jan 2026 14:19:22 -0600 Subject: [PATCH 14/14] backend: simplify exception handling, don't insert user-agent These changes remove some overly broad exceptions and restructure the core request logic in a few ways: - Remove insert a user-agent; I compared other SDKs and they do not add this, so we shouldn't either. - Don't override content-type if set explicitly. - Simplified Host header handling; there was a workaround that is now patched in viceroy and I think not necessary. - Reduced the sites where we perform hostcalls in request and made those sites be very specific with exception handling. --- fastly_compute/requests/__init__.py | 100 ++++++++++------------------ 1 file changed, 36 insertions(+), 64 deletions(-) diff --git a/fastly_compute/requests/__init__.py b/fastly_compute/requests/__init__.py index bccccd4..a37d223 100644 --- a/fastly_compute/requests/__init__.py +++ b/fastly_compute/requests/__init__.py @@ -41,7 +41,7 @@ import urllib.parse from typing import Any, TypedDict, Unpack -from wit_world.imports import async_io, http_body, http_req +from wit_world.imports import http_body, http_req from wit_world.types import Err from fastly_compute.requests.backend import resolve_backend @@ -178,12 +178,6 @@ def options(url: str, **kwargs: Unpack[RequestKwargs]) -> FastlyResponse: return request("OPTIONS", url, **kwargs) -def _http_body_write_all(body: async_io.Pollable, buf: bytes): - written = 0 - while written < len(buf): - written += http_body.write(body, buf) - - def request( method: str, url: str, @@ -251,70 +245,48 @@ def request( raise RequestException.from_wit_error(e, "create_req") from e # Set headers - try: - # TODO: See https://github.com/fastly/Viceroy/pull/549; what is - # present here is a temporary workaround for viceroy differing - # in its handling than XQD. - # - # We'll always set a host header in the following order here: - # - If the header is set explicitly, use that - # - Use the netloc on the parsed url which comes from: - # - The netloc for this request OR - # - The netloc from the registered backend - headers = headers if headers is not None else {} - if fastly_backend is not None: - host_header = headers.pop("Host", url_parsed.netloc) - wit_request.insert_header("Host", host_header.encode("utf-8")) - - # Set default User-Agent only if not provided - # Check for both exact case and lowercase variants - has_user_agent = any(name.lower() == "user-agent" for name in headers.keys()) - if not has_user_agent: - wit_request.insert_header("User-Agent", b"FastlyCompute-Requests/1.0") - - # Add custom headers - for name, value in headers.items(): + headers = headers if headers is not None else {} + if fastly_backend is not None: + host_header = headers.pop("Host", url_parsed.netloc) + wit_request.insert_header("Host", host_header.encode("utf-8")) + + body: bytes | None = None + if json is not None: + # JSON data - use the json module, not the parameter + json_str = json if isinstance(json, str) else json_module.dumps(json) + body = json_str.encode("utf-8") + headers.setdefault("Content-Type", "application/json") + elif data is None: + pass + elif isinstance(data, dict): + # Form data + headers.setdefault("Content-Type", "application/x-www-form-urlencoded") + body = urllib.parse.urlencode(data).encode("utf-8") + else: + # str | bytes + body = data.encode("utf-8") if isinstance(data, str) else data + + # Add headers + for name, value in headers.items(): + try: wit_request.insert_header(name, value.encode("utf-8")) - except ValueError as e: - raise RequestException(f"Invalid headers: {e}") from e - except Err as e: - raise RequestException.from_wit_error(e, "set_request_headers") from e + except Err as e: + raise RequestException.from_wit_error(e, "insert_header") from e # Prepare request body - try: - request_body = http_body.new() - except Err as e: - raise RequestException.from_wit_error(e, "http_body.new") from e - - try: - if json is not None: - # JSON data - use the json module, not the parameter - json_str = json if isinstance(json, str) else json_module.dumps(json) - json_bytes = json_str.encode("utf-8") - wit_request.insert_header("Content-Type", b"application/json") - _http_body_write_all(request_body, json_bytes) - elif data is None: - pass - elif isinstance(data, dict): - # Form data - form_data = urllib.parse.urlencode(data).encode("utf-8") - wit_request.insert_header( - "Content-Type", b"application/x-www-form-urlencoded" - ) - _http_body_write_all(request_body, form_data) - else: - # str | bytes - data_bytes = data.encode("utf-8") if isinstance(data, str) else data - _http_body_write_all(request_body, data_bytes) - except (TypeError, ValueError, UnicodeError) as e: - raise RequestException(f"Invalid request body: {e}") from e - except Err as e: - raise RequestException.from_wit_error(e, "write_body") from e + wit_body = http_body.new() + if body: + try: + written = 0 + while written < len(body): + written += http_body.write(wit_body, body) + except Err as e: + raise RequestException.from_wit_error(e, "http_body.write") from e # Send the request try: wit_response, response_body = http_req.send( - wit_request, request_body, resolution.backend + wit_request, wit_body, resolution.backend ) except Err as e: # WIT-level errors during request execution - use proper error classification