From df170eddb05b0ba9e4444c3de614ed0e4de0407d Mon Sep 17 00:00:00 2001 From: Gabriel Fonseca Date: Sun, 11 Oct 2026 17:15:49 +0100 Subject: [PATCH 01/29] feat(core): add circuit_breaker, retry, and constants - Add CircuitBreaker and CircuitBreakerConfig for failure protection - Add calculate_delay() with exponential/linear/constant backoff strategies - Add shared constants: API_KEY_PREFIX, DEFAULT_BASE_URL, SDK_VERSION, retry settings --- frontal_sdk/core/circuit_breaker.py | 200 ++++++++++++++++++++++++++++ frontal_sdk/core/constants.py | 32 +++++ frontal_sdk/core/retry.py | 47 +++++++ 3 files changed, 279 insertions(+) create mode 100644 frontal_sdk/core/circuit_breaker.py create mode 100644 frontal_sdk/core/constants.py create mode 100644 frontal_sdk/core/retry.py diff --git a/frontal_sdk/core/circuit_breaker.py b/frontal_sdk/core/circuit_breaker.py new file mode 100644 index 0000000..ba55bd6 --- /dev/null +++ b/frontal_sdk/core/circuit_breaker.py @@ -0,0 +1,200 @@ +"""Circuit breaker that guards API calls from cascading failures.""" + +from __future__ import annotations + +import threading +import time +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Literal, TypeVar + +from frontal_sdk.core.errors import FrontalError + +CircuitState = Literal["closed", "open", "half_open"] + +ResultT = TypeVar("ResultT") + + +@dataclass(frozen=True) +class CircuitBreakerConfig: + """Configuration for :class:`CircuitBreaker`. + + ``failure_threshold`` consecutive failures open the circuit; + ``reset_timeout`` seconds later it half-opens and lets one + probe request through. The ``on_*`` callbacks fire on the + matching transition. + """ + + failure_threshold: int = 5 + reset_timeout: float = 30.0 + on_open: Callable[[], None] | None = None + on_close: Callable[[], None] | None = None + on_half_open: Callable[[], None] | None = None + + def __post_init__(self) -> None: + if ( + isinstance(self.failure_threshold, bool) + or not isinstance(self.failure_threshold, int) + ): + raise TypeError("failure_threshold must be an integer") + if self.failure_threshold < 1: + raise ValueError("failure_threshold must be positive") + if not isinstance(self.reset_timeout, (int, float)): + raise TypeError("reset_timeout must be a number") + if self.reset_timeout <= 0: + raise ValueError("reset_timeout must be positive") + + +class CircuitBreakerOpenError(FrontalError): + """A request was rejected because the circuit breaker is open.""" + + def __init__(self, retry_after: float) -> None: + super().__init__( + f"Circuit breaker is open — retry after {retry_after:g} seconds", + code="CIRCUIT_BREAKER_OPEN", + ) + self.retry_after = retry_after + + +class CircuitBreaker: + """Counts consecutive failures and short-circuits requests. + + While the circuit is ``open`` (or a half-open probe is in + flight) calls raise :class:`CircuitBreakerOpenError` instead of + reaching the API. A successful probe closes the circuit again. + """ + + def __init__(self, config: CircuitBreakerConfig | None = None) -> None: + self._config = config or CircuitBreakerConfig() + self._lock = threading.Lock() + self._failures = 0 + self._last_failure = 0.0 + self._state: CircuitState = "closed" + self._probe_in_flight = False + + @property + def state(self) -> CircuitState: + """The current circuit state.""" + with self._lock: + return self._state + + @property + def failures(self) -> int: + """The current consecutive failure count.""" + with self._lock: + return self._failures + + def execute( + self, + fn: Callable[[], ResultT], + is_failure: Callable[[ResultT], bool] | None = None, + ) -> ResultT: + """Run ``fn`` through the breaker. + + ``is_failure`` classifies a returned (non-raising) result + as a failure, so HTTP error responses can count too. + """ + probe = self._enter() + try: + try: + result = fn() + failed = is_failure is not None and is_failure(result) + except BaseException: + with self._lock: + self._record_failure(probe) + raise + with self._lock: + if failed: + self._record_failure(probe) + elif probe: + self._reset() + else: + self._failures = 0 + return result + finally: + with self._lock: + if probe: + self._probe_in_flight = False + + async def execute_async( + self, + fn: Callable[[], Awaitable[ResultT]], + is_failure: Callable[[ResultT], bool] | None = None, + ) -> ResultT: + """Run an awaitable ``fn`` through the breaker.""" + probe = self._enter() + try: + try: + result = await fn() + failed = is_failure is not None and is_failure(result) + except BaseException: + with self._lock: + self._record_failure(probe) + raise + with self._lock: + if failed: + self._record_failure(probe) + elif probe: + self._reset() + else: + self._failures = 0 + return result + finally: + with self._lock: + if probe: + self._probe_in_flight = False + + def force_open(self) -> None: + """Manually force the circuit into the ``open`` state.""" + with self._lock: + self._state = "open" + self._last_failure = time.monotonic() + self._probe_in_flight = False + + def force_close(self) -> None: + """Manually reset the circuit to the ``closed`` state.""" + with self._lock: + self._reset() + + def _enter(self) -> bool: + """Apply state transitions before a call; return whether + this call is a half-open probe.""" + with self._lock: + if self._state == "open": + elapsed = time.monotonic() - self._last_failure + if elapsed <= self._config.reset_timeout: + raise CircuitBreakerOpenError( + self._config.reset_timeout - elapsed + ) + self._state = "half_open" + if self._config.on_half_open is not None: + self._config.on_half_open() + probe = self._state == "half_open" + if probe: + if self._probe_in_flight: + raise CircuitBreakerOpenError(self._config.reset_timeout) + self._probe_in_flight = True + return probe + + def _record_failure(self, probe: bool) -> None: + self._failures += 1 + self._last_failure = time.monotonic() + if probe or self._failures >= self._config.failure_threshold: + if self._state != "open": + self._state = "open" + if self._config.on_open is not None: + self._config.on_open() + + def _reset(self) -> None: + self._failures = 0 + self._state = "closed" + if self._config.on_close is not None: + self._config.on_close() + + +__all__ = [ + "CircuitBreaker", + "CircuitBreakerConfig", + "CircuitBreakerOpenError", + "CircuitState", +] diff --git a/frontal_sdk/core/constants.py b/frontal_sdk/core/constants.py new file mode 100644 index 0000000..2011e24 --- /dev/null +++ b/frontal_sdk/core/constants.py @@ -0,0 +1,32 @@ +"""Shared constants for the Frontal Python SDK.""" + +from __future__ import annotations + +API_KEY_PREFIX = "frt_" +DEFAULT_BASE_URL = "https://api.frontal.dev/v1" +EXPONENTIAL_BASE = 2 +JITTER_MAX = 0.1 +SDK_VERSION = "1.0.0" + +#: Status codes that trigger an automatic GET retry. Rate limits and +#: server errors are replay-safe; client errors and 408/425 only mark +#: an error as transient, never retried automatically. +DEFAULT_RETRY_ON = (429, 500, 502, 503, 504) + +#: Statuses that classify an error as transient (retryable by the +#: caller), matching the wider set the API may report. +TRANSIENT_STATUS = frozenset({408, 425, 429, 500, 502, 503, 504}) + +#: Server failures the circuit breaker counts towards opening. +BREAKER_FAILURE_STATUS = frozenset({500, 502, 503, 504}) + +__all__ = [ + "API_KEY_PREFIX", + "BREAKER_FAILURE_STATUS", + "DEFAULT_BASE_URL", + "DEFAULT_RETRY_ON", + "EXPONENTIAL_BASE", + "JITTER_MAX", + "SDK_VERSION", + "TRANSIENT_STATUS", +] diff --git a/frontal_sdk/core/retry.py b/frontal_sdk/core/retry.py new file mode 100644 index 0000000..dfb8b7e --- /dev/null +++ b/frontal_sdk/core/retry.py @@ -0,0 +1,47 @@ +"""Retry backoff strategies shared by the transports.""" + +from __future__ import annotations + +import random +from math import isfinite +from typing import Literal + +from frontal_sdk.core.constants import EXPONENTIAL_BASE, JITTER_MAX + +BackoffStrategy = Literal["exponential", "linear", "constant"] + + +def calculate_delay( + attempt: int, + *, + strategy: BackoffStrategy = "exponential", + base_delay: float = 1.0, + jitter: bool = True, +) -> float: + """Return the delay in seconds before retrying ``attempt``. + + ``exponential`` grows by ``base_delay * 2**attempt``, ``linear`` by + ``base_delay * (attempt + 1)``, and ``constant`` stays at + ``base_delay``. Jitter adds up to ``JITTER_MAX`` seconds to spread + concurrent retries. + """ + if isinstance(attempt, bool) or not isinstance(attempt, int): + raise TypeError("attempt must be an integer") + if attempt < 0: + raise ValueError("attempt must be non-negative") + if strategy not in {"exponential", "linear", "constant"}: + raise ValueError("unsupported backoff strategy") + if not isfinite(base_delay) or base_delay < 0: + raise ValueError("base_delay must be a finite non-negative number") + if strategy == "linear": + delay = base_delay * (attempt + 1) + elif strategy == "constant": + delay = base_delay + else: + delay = base_delay * float(EXPONENTIAL_BASE**attempt) + if not jitter: + return delay + return delay + random.uniform(0.0, JITTER_MAX) + + +__all__ = ["BackoffStrategy", "calculate_delay"] From b8c9b615a3d4f7fdc00e5cbc32da8b1bf4813ed6 Mon Sep 17 00:00:00 2001 From: Gabriel Fonseca Date: Sun, 11 Oct 2026 17:15:54 +0100 Subject: [PATCH 02/29] feat(core): enhance error_for_status() with fix messages and error codes - Add field to all FrontalError subclasses for remediation hints - Preserve API error codes in error_for_status() output - Add human-readable fix messages for common status codes (401, 404, 409, 429, 400/422, 5xx) - Update __init__.py exports to include new types and utilities --- frontal_sdk/core/__init__.py | 11 ++++ frontal_sdk/core/errors.py | 101 +++++++++++++++++++++++++++++++---- 2 files changed, 102 insertions(+), 10 deletions(-) diff --git a/frontal_sdk/core/__init__.py b/frontal_sdk/core/__init__.py index 3386506..2e8559e 100644 --- a/frontal_sdk/core/__init__.py +++ b/frontal_sdk/core/__init__.py @@ -1,5 +1,7 @@ """Core configuration, transport, and error types.""" +from __future__ import annotations + from frontal_sdk.core.config import ClientConfig from frontal_sdk.core.errors import ( AuthenticationError, @@ -13,17 +15,23 @@ ValidationError, ) from frontal_sdk.core.http import AsyncHttpClient, HttpClient +from frontal_sdk.core.constants import DEFAULT_BASE_URL, SDK_VERSION +from frontal_sdk.core.circuit_breaker import CircuitBreaker, CircuitBreakerConfig from frontal_sdk.core.operation import Operation from frontal_sdk.core.pagination import async_paginate, paginate from frontal_sdk.core.polling import async_poll_until, poll_until +from frontal_sdk.core.retry import calculate_delay, BackoffStrategy __all__ = [ "AsyncHttpClient", + "BackendProtocol", "async_paginate", "async_poll_until", "AuthenticationError", "ClientConfig", "ConflictError", + "CircuitBreaker", + "CircuitBreakerConfig", "FrontalError", "HttpClient", "NetworkError", @@ -35,4 +43,7 @@ "ServerError", "TimeoutError", "ValidationError", + "calculate_delay", + "SDK_VERSION", + "BackoffStrategy", ] diff --git a/frontal_sdk/core/errors.py b/frontal_sdk/core/errors.py index 86ac980..5a1a79b 100644 --- a/frontal_sdk/core/errors.py +++ b/frontal_sdk/core/errors.py @@ -32,6 +32,7 @@ def __init__( safe_to_retry: bool = False, details: JSONValue = None, fields: list[ErrorField] | None = None, + fix: str | None = None, ) -> None: super().__init__(message) self.code = code or "FRONTAL_ERROR" @@ -43,6 +44,7 @@ def __init__( self.safe_to_retry = safe_to_retry self.details = details self.fields = fields or [] + self.fix = fix class AuthenticationError(FrontalError): @@ -71,6 +73,7 @@ def __init__( details: JSONValue = None, fields: list[ErrorField] | None = None, safe_to_retry: bool = False, + fix: str | None = None, ) -> None: super().__init__( message, @@ -81,6 +84,7 @@ def __init__( safe_to_retry=safe_to_retry, details=details, fields=fields, + fix=fix, ) self.retry_after = retry_after @@ -132,28 +136,105 @@ def error_for_status( retry_after: float | None = None, safe_to_retry: bool = False, ) -> FrontalError: - """Create the public error subtype corresponding to an HTTP status.""" + """Create the public error subtype corresponding to an HTTP status. + + The returned error carries the API's error ``code`` when available, a + human-readable ``fix`` remediation hint, and optional ``rateLimit`` + metadata from response headers. The ``safe_to_retry`` flag indicates + whether retrying the same request may succeed (rate limits, transient + 5xx, network). UIs and agents can branch on this without a status table. + """ + # Build context without ``code`` so we can pass it as a named arg. arguments: _ErrorContext = { - "code": code, "request_id": request_id, "status_code": status_code, "details": details, "fields": fields, "safe_to_retry": safe_to_retry, } + effective_code = code + if status_code in {401, 403}: - return AuthenticationError(message, **arguments) + fix = ( + "Check FRONTAL_API_KEY — it must be a valid key starting with frt_." + if effective_code in {"UNAUTHORIZED", "INVALID_API_KEY"} + else None + ) + return AuthenticationError( + message, + code=effective_code, + fix=fix, + **arguments + ) if status_code == 404: - return NotFoundError(message, **arguments) + return NotFoundError( + message, + code=effective_code, + fix=( + "Verify the resource id and that it belongs to this workspace/environment." + if effective_code == "NOT_FOUND" + else None + ), + **arguments + ) if status_code == 409: - return ConflictError(message, **arguments) + return ConflictError( + message, + code=effective_code, + fix=( + "Inspect `fields` for the offending properties and fix the request payload." + if effective_code == "VALIDATION_ERROR" + else None + ), + **arguments + ) if status_code == 429: - return RateLimitError(message, retry_after=retry_after, **arguments) + return RateLimitError( + message, + code=effective_code, + retry_after=retry_after, + fix=( + "Back off and retry after the `retryAfter` interval, or lower request concurrency." + if effective_code == "RATE_LIMITED" + else None + ), + **arguments + ) if status_code in {400, 422}: - return ValidationError(message, **arguments) + return ValidationError( + message, + code=effective_code, + fix=( + "Inspect `fields` for the offending properties and fix the request payload." + if effective_code in {"BAD_REQUEST", "VALIDATION_ERROR"} + else None + ), + **arguments + ) if status_code >= 500: - return ServerError(message, **arguments) - return FrontalError(message, **arguments) + return ServerError( + message, + code=effective_code, + fix=( + "Service is temporarily unavailable; retry with backoff." + if effective_code in {"SERVICE_UNAVAILABLE", "INTERNAL_ERROR"} + else None + ), + **arguments + ) + # For unmapped status codes return a generic FrontalError carrying the + # API's error code when known. + fix = ( + "Check the API response for details." + if effective_code is None + else None + ) + return FrontalError( + message, + code=effective_code, + fix=fix, + **arguments + ) __all__ = [ @@ -167,4 +248,4 @@ def error_for_status( "TimeoutError", "ValidationError", "error_for_status", -] +] \ No newline at end of file From 6eb64fada00e9dd608a6a9480f5b61ef6132c11d Mon Sep 17 00:00:00 2001 From: Gabriel Fonseca Date: Sun, 11 Oct 2026 17:16:01 +0100 Subject: [PATCH 03/29] fix(sdk): restrict safe_to_retry to idempotent methods only - safe_to_retry now only applies to GET/HEAD/OPTIONS methods - Mutation requests (POST/PUT/DELETE) are never auto-replayed - This aligns with TypeSDK SDK policy: mutation requests are never replayed automatically - Update _api_error() to compute _safe based on method idempotency --- frontal_sdk/core/http.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frontal_sdk/core/http.py b/frontal_sdk/core/http.py index e560eca..09f9daa 100644 --- a/frontal_sdk/core/http.py +++ b/frontal_sdk/core/http.py @@ -167,6 +167,10 @@ def _api_error(response: httpx.Response, method: str) -> FrontalError: elif response.content: details = decoded if isinstance(decoded, (dict, list)) else response.text[:2048] + # ``safe_to_retry``: GET/HEAD/OPTIONS are idempotent; all other methods + # (POST/PUT/DELETE) are not automatically replayed — TS documents this + # policy ("Mutation requests are never replayed automatically"). + _safe = method in {"GET", "HEAD", "OPTIONS"} return error_for_status( response.status_code, message, @@ -175,7 +179,7 @@ def _api_error(response: httpx.Response, method: str) -> FrontalError: details=details, fields=fields, retry_after=_parse_retry_after(response.headers.get("retry-after")), - safe_to_retry=method in {"GET", "HEAD", "OPTIONS", "PUT", "DELETE"}, + safe_to_retry=_safe, ) From 6b848ab5c92b08ed94ac3dc884c49015cfbb4654 Mon Sep 17 00:00:00 2001 From: Gabriel Fonseca Date: Sun, 11 Oct 2026 17:16:07 +0100 Subject: [PATCH 04/29] feat(sdk): add lowercase namespace aliases for API resources - Add with lowercase convenience aliases (agents, ai, audit, auth, etc.) - Aliases map to the same typed classes from for backward compatibility - New code can use - Existing imports from continue to work unchanged --- frontal_sdk/api/__init__.py | 72 +++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 frontal_sdk/api/__init__.py diff --git a/frontal_sdk/api/__init__.py b/frontal_sdk/api/__init__.py new file mode 100644 index 0000000..81d986e --- /dev/null +++ b/frontal_sdk/api/__init__.py @@ -0,0 +1,72 @@ +"""API resources for the Frontal Python SDK. + +This package re-exports the typed API resources from the ``resources`` +sub-package for backward compatibility, while providing a clean +``api.*`` namespace for new code. + +Typical usage:: + + from frontal_sdk import Frontal + from frontal_sdk.api import agents, ai, workflows + + with Frontal() as client: + agent = client.agents.define("MyAgent") + result = client.ai.generate_text(...) + runs = client.workflows.use("wf_123").executions() +""" +from frontal_sdk.resources import ( # noqa: F401 + Agents, + AgentAccessor, + AgentBuilder, + AI, + AsyncAI, + Audit, + Auth, + Billing, + Blob, + Connectors, + Data, + FunctionAccessor, + FunctionBuilder, + Functions, + Governance, + Lineage, + Observability, + Ontology, + Pipelines, + Schedules, + Webhooks, + WorkflowAccessor, + WorkflowApprovals, + WorkflowBuilder, + WorkflowSteps, + WorkflowTemplates, + Workflows, + SyncAI, + tool, +) + +# Add lowercase convenience aliases for API imports +agents = Agents +ai = AI +audit = Audit +auth = Auth +billing = Billing +blob = Blob +connectors = Connectors +data = Data +functions = Functions +governance = Governance +lineage = Lineage +observability = Observability +ontology = Ontology +pipelines = Pipelines +schedules = Schedules +webhooks = Webhooks +workflow_accessor = WorkflowAccessor +workflow_approvals = WorkflowApprovals +workflow_builder = WorkflowBuilder +workflow_steps = WorkflowSteps +workflow_templates = WorkflowTemplates +workflows = Workflows +syncai = SyncAI \ No newline at end of file From 53ab3a09d4d8b8e59f1944891ab71d32f814339a Mon Sep 17 00:00:00 2001 From: Gabriel Fonseca Date: Sun, 11 Oct 2026 17:16:14 +0100 Subject: [PATCH 05/29] feat(sdk): add shared utilities package - Add utils/ package with shared constants, retry, circuit_breaker, and error helpers - utils/constants.py: API_KEY_PREFIX, DEFAULT_BASE_URL, SDK_VERSION, retry settings - utils/retry.py: calculate_delay() with exponential/linear/constant backoff strategies and jitter - utils/circuit_breaker.py: CircuitBreaker and CircuitBreakerConfig utilities - utils/errors.py: error_for_status, FrontalError, RateLimitError re-exports - utils/__init__.py: package-level re-exports for convenient imports --- frontal_sdk/utils/__init__.py | 42 +++++++++++++++++++++++++ frontal_sdk/utils/circuit_breaker.py | 45 ++++++++++++++++++++++++++ frontal_sdk/utils/constants.py | 31 ++++++++++++++++++ frontal_sdk/utils/errors.py | 8 +++++ frontal_sdk/utils/retry.py | 47 ++++++++++++++++++++++++++++ 5 files changed, 173 insertions(+) create mode 100644 frontal_sdk/utils/__init__.py create mode 100644 frontal_sdk/utils/circuit_breaker.py create mode 100644 frontal_sdk/utils/constants.py create mode 100644 frontal_sdk/utils/errors.py create mode 100644 frontal_sdk/utils/retry.py diff --git a/frontal_sdk/utils/__init__.py b/frontal_sdk/utils/__init__.py new file mode 100644 index 0000000..fb4add1 --- /dev/null +++ b/frontal_sdk/utils/__init__.py @@ -0,0 +1,42 @@ +"""Utility functions shared across the Frontal Python SDK. + +This package is optional and provides helpers that supplement the +core functionality without being tied to any particular API domain. + +Available utilities: +- ``retry`` — backoff delay calculation (see :mod:`frontal_sdk.utils.retry`) +- ``circuit_breaker`` — circuit breaker (see :mod:`frontal_sdk.utils.circuit_breaker`) +- ``constants`` — SDK-wide constants (see :mod:`frontal_sdk.utils.constants`) +- ``errors`` — error helpers (see :mod:`frontal_sdk.utils.errors`) +""" +from frontal_sdk.utils.constants import ( # noqa: F401 + API_KEY_PREFIX, + DEFAULT_BASE_URL, + SDK_VERSION, + DEFAULT_RETRY_ON, + TRANSIENT_STATUS, + BREAKER_FAILURE_STATUS, + EXPONENTIAL_BASE, + JITTER_MAX, +) +from frontal_sdk.utils.errors import error_for_status, FrontalError, RateLimitError # noqa: F401 +from frontal_sdk.utils.retry import calculate_delay, BackoffStrategy # noqa: F401 +from frontal_sdk.utils.circuit_breaker import CircuitBreaker, CircuitBreakerConfig # noqa: F401 + +__all__ = [ + "API_KEY_PREFIX", + "DEFAULT_BASE_URL", + "SDK_VERSION", + "DEFAULT_RETRY_ON", + "TRANSIENT_STATUS", + "BREAKER_FAILURE_STATUS", + "EXPONENTIAL_BASE", + "JITTER_MAX", + "error_for_status", + "FrontalError", + "RateLimitError", + "calculate_delay", + "BackoffStrategy", + "CircuitBreaker", + "CircuitBreakerConfig", +] \ No newline at end of file diff --git a/frontal_sdk/utils/circuit_breaker.py b/frontal_sdk/utils/circuit_breaker.py new file mode 100644 index 0000000..f69bc6c --- /dev/null +++ b/frontal_sdk/utils/circuit_breaker.py @@ -0,0 +1,45 @@ +"""Circuit breaker utilities for the Frontal Python SDK.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal, Callable, TypeVar + +from frontal_sdk.core.circuit_breaker import CircuitBreaker, CircuitBreakerConfig + +CircuitState = Literal["closed", "open", "half_open"] + +ResultT = TypeVar("ResultT") + + +@dataclass(frozen=True) +class CircuitBreakerConfig: + """Configuration for :class:`CircuitBreaker`. + + ``failure_threshold`` consecutive failures open the circuit; + ``reset_timeout`` seconds later it half-opens and lets one + probe request through. The ``on_*`` callbacks fire on the + matching transition. + """ + + failure_threshold: int = 5 + reset_timeout: float = 30.0 + on_open: Callable[[], None] | None = None + on_close: Callable[[], None] | None = None + on_half_open: Callable[[], None] | None = None + + def __post_init__(self) -> None: + if ( + isinstance(self.failure_threshold, bool) + or not isinstance(self.failure_threshold, int) + ): + raise TypeError("failure_threshold must be an integer") + if self.failure_threshold < 1: + raise ValueError("failure_threshold must be positive") + if not isinstance(self.reset_timeout, (int, float)): + raise TypeError("reset_timeout must be a number") + if self.reset_timeout <= 0: + raise ValueError("reset_timeout must be positive") + + +__all__ = ["CircuitBreaker", "CircuitBreakerConfig"] \ No newline at end of file diff --git a/frontal_sdk/utils/constants.py b/frontal_sdk/utils/constants.py new file mode 100644 index 0000000..d437302 --- /dev/null +++ b/frontal_sdk/utils/constants.py @@ -0,0 +1,31 @@ +"""SDK-wide constants for the Frontal Python SDK.""" + +API_KEY_PREFIX = "frt_" +DEFAULT_BASE_URL = "https://api.frontal.dev/v1" +SDK_VERSION = "1.0.0" + +#: Status codes that trigger an automatic GET retry. +DEFAULT_RETRY_ON = (429, 500, 502, 503, 504) + +#: Statuses that classify an error as transient (retryable by the caller). +TRANSIENT_STATUS = frozenset({408, 425, 429, 500, 502, 503, 504}) + +#: Server failures the circuit breaker counts towards opening. +BREAKER_FAILURE_STATUS = frozenset({500, 502, 503, 504}) + +#: Backoff base multiplier for exponential delay. +EXPONENTIAL_BASE = 2 + +#: Maximum jitter (seconds) added to retry delays. +JITTER_MAX = 0.1 + +__all__ = [ + "API_KEY_PREFIX", + "DEFAULT_BASE_URL", + "SDK_VERSION", + "DEFAULT_RETRY_ON", + "TRANSIENT_STATUS", + "BREAKER_FAILURE_STATUS", + "EXPONENTIAL_BASE", + "JITTER_MAX", +] \ No newline at end of file diff --git a/frontal_sdk/utils/errors.py b/frontal_sdk/utils/errors.py new file mode 100644 index 0000000..f634b31 --- /dev/null +++ b/frontal_sdk/utils/errors.py @@ -0,0 +1,8 @@ +"""Error helper functions for the Frontal Python SDK.""" + +from __future__ import annotations + +from frontal_sdk.core.errors import error_for_status, FrontalError, RateLimitError + + +__all__ = ["error_for_status", "FrontalError", "RateLimitError"] \ No newline at end of file diff --git a/frontal_sdk/utils/retry.py b/frontal_sdk/utils/retry.py new file mode 100644 index 0000000..d711dfe --- /dev/null +++ b/frontal_sdk/utils/retry.py @@ -0,0 +1,47 @@ +"""Retry backoff strategies shared across the Frontal Python SDK.""" + +from __future__ import annotations + +import random +from math import isfinite +from typing import Literal + +from frontal_sdk.utils.constants import EXPONENTIAL_BASE, JITTER_MAX + +BackoffStrategy = Literal["exponential", "linear", "constant"] + + +def calculate_delay( + attempt: int, + *, + strategy: BackoffStrategy = "exponential", + base_delay: float = 1.0, + jitter: bool = True, +) -> float: + """Return the delay in seconds before retrying ``attempt``. + + ``exponential`` grows by ``base_delay * 2**attempt``, ``linear`` by + ``base_delay * (attempt + 1)``, and ``constant`` stays at + ``base_delay``. Jitter adds up to ``JITTER_MAX`` seconds to spread + concurrent retries. + """ + if isinstance(attempt, bool) or not isinstance(attempt, int): + raise TypeError("attempt must be an integer") + if attempt < 0: + raise ValueError("attempt must be non-negative") + if strategy not in {"exponential", "linear", "constant"}: + raise ValueError("unsupported backoff strategy") + if not isfinite(base_delay) or base_delay < 0: + raise ValueError("base_delay must be a finite non-negative number") + if strategy == "linear": + delay = base_delay * (attempt + 1) + elif strategy == "constant": + delay = base_delay + else: + delay = base_delay * float(EXPONENTIAL_BASE**attempt) + if not jitter: + return delay + return delay + random.uniform(0.0, JITTER_MAX) + + +__all__ = ["BackoffStrategy", "calculate_delay"] \ No newline at end of file From 847275b202386b315040a6c6659ee94d92439ee4 Mon Sep 17 00:00:00 2001 From: Gabriel Fonseca Date: Sun, 11 Oct 2026 17:16:19 +0100 Subject: [PATCH 06/29] feat(sdk): add module entry point for python -m frontal_sdk - Add __main__.py to enable running the SDK as - Prints SDK version and available services (agents, ai, workflows, etc.) - Checks for FRONTAL_API_KEY environment variable - Useful for quick diagnostics and testing --- frontal_sdk/__main__.py | 65 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 frontal_sdk/__main__.py diff --git a/frontal_sdk/__main__.py b/frontal_sdk/__main__.py new file mode 100644 index 0000000..2bff6e7 --- /dev/null +++ b/frontal_sdk/__main__.py @@ -0,0 +1,65 @@ +"""Allow running the Frontal Python SDK as a module: ``python -m frontal_sdk``. + +This is useful for quick diagnostics, testing, and documentation purposes. +""" +from __future__ import annotations + +import sys +from frontal_sdk import __version__, Frontal + +DEFAULT_API_KEY_ENV = "FRONTAL_API_KEY" + + +def _check_api_key() -> bool: + """Check if a Frontal API key is available in the environment.""" + import os + return bool( + os.environ.get("FRONTAL_API_KEY") + or os.environ.get("FRONTAL_API_KEY_OLD") + ) + + +def main() -> None: + """Entry point for ``python -m frontal_sdk``.""" + print(f"Frontal Python SDK v{__version__}") + print("=" * 40) + + has_key = _check_api_key() + if has_key: + print("✓ Frontal API key detected in environment variables") + else: + print("⚠ No Frontal API key found (set FRONTAL_API_KEY)") + print(" Set FRONTAL_API_KEY to a key starting with 'frt_'") + print(" to make live API calls.") + + print() + print("Available services:") + services = [ + "agents", + "ai", + "workflows", + "functions", + "audit", + "auth", + "billing", + "blob", + "connectors", + "data", + "governance", + "lineage", + "observability", + "ontology", + "pipelines", + "schedules", + "webhooks", + ] + for svc in services: + print(f" - {svc}") + + print() + print("Use ``from frontal_sdk import Frontal`` to start coding.") + print("See ``https://frontal.dev/docs/python`` for the full documentation.") + + +if __name__ == "__main__": # pragma: no cover + main() \ No newline at end of file From d0f1d231b92163aa4ee843078c072eef32ded97a Mon Sep 17 00:00:00 2001 From: Gabriel Fonseca Date: Sun, 11 Oct 2026 18:00:22 +0100 Subject: [PATCH 07/29] refactor(sdk): re-export circuit breaker helpers from core frontal_sdk.utils.circuit_breaker duplicated the canonical implementation in frontal_sdk.core.circuit_breaker. Replace the duplicate with a thin re-export so both import paths share one implementation, and publish FrontalError, RateLimitError, and the circuit breaker helpers from the utils package surface. --- frontal_sdk/utils/__init__.py | 22 ++++++---- frontal_sdk/utils/circuit_breaker.py | 61 +++++++++------------------- frontal_sdk/utils/errors.py | 5 +-- 3 files changed, 36 insertions(+), 52 deletions(-) diff --git a/frontal_sdk/utils/__init__.py b/frontal_sdk/utils/__init__.py index fb4add1..859c69f 100644 --- a/frontal_sdk/utils/__init__.py +++ b/frontal_sdk/utils/__init__.py @@ -9,19 +9,27 @@ - ``constants`` — SDK-wide constants (see :mod:`frontal_sdk.utils.constants`) - ``errors`` — error helpers (see :mod:`frontal_sdk.utils.errors`) """ + +from frontal_sdk.utils.circuit_breaker import ( # noqa: F401 + CircuitBreaker, + CircuitBreakerConfig, +) from frontal_sdk.utils.constants import ( # noqa: F401 API_KEY_PREFIX, + BREAKER_FAILURE_STATUS, DEFAULT_BASE_URL, - SDK_VERSION, DEFAULT_RETRY_ON, - TRANSIENT_STATUS, - BREAKER_FAILURE_STATUS, EXPONENTIAL_BASE, JITTER_MAX, + SDK_VERSION, + TRANSIENT_STATUS, +) +from frontal_sdk.utils.errors import ( # noqa: F401 + FrontalError, + RateLimitError, + error_for_status, ) -from frontal_sdk.utils.errors import error_for_status, FrontalError, RateLimitError # noqa: F401 -from frontal_sdk.utils.retry import calculate_delay, BackoffStrategy # noqa: F401 -from frontal_sdk.utils.circuit_breaker import CircuitBreaker, CircuitBreakerConfig # noqa: F401 +from frontal_sdk.utils.retry import BackoffStrategy, calculate_delay # noqa: F401 __all__ = [ "API_KEY_PREFIX", @@ -39,4 +47,4 @@ "BackoffStrategy", "CircuitBreaker", "CircuitBreakerConfig", -] \ No newline at end of file +] diff --git a/frontal_sdk/utils/circuit_breaker.py b/frontal_sdk/utils/circuit_breaker.py index f69bc6c..23cdf3c 100644 --- a/frontal_sdk/utils/circuit_breaker.py +++ b/frontal_sdk/utils/circuit_breaker.py @@ -1,45 +1,22 @@ -"""Circuit breaker utilities for the Frontal Python SDK.""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import Literal, Callable, TypeVar - -from frontal_sdk.core.circuit_breaker import CircuitBreaker, CircuitBreakerConfig - -CircuitState = Literal["closed", "open", "half_open"] - -ResultT = TypeVar("ResultT") +"""Circuit breaker utilities for the Frontal Python SDK. +This module re-exports the canonical implementation from +:mod:`frontal_sdk.core.circuit_breaker` so that utility consumers +have a single import location. +""" -@dataclass(frozen=True) -class CircuitBreakerConfig: - """Configuration for :class:`CircuitBreaker`. - - ``failure_threshold`` consecutive failures open the circuit; - ``reset_timeout`` seconds later it half-opens and lets one - probe request through. The ``on_*`` callbacks fire on the - matching transition. - """ - - failure_threshold: int = 5 - reset_timeout: float = 30.0 - on_open: Callable[[], None] | None = None - on_close: Callable[[], None] | None = None - on_half_open: Callable[[], None] | None = None - - def __post_init__(self) -> None: - if ( - isinstance(self.failure_threshold, bool) - or not isinstance(self.failure_threshold, int) - ): - raise TypeError("failure_threshold must be an integer") - if self.failure_threshold < 1: - raise ValueError("failure_threshold must be positive") - if not isinstance(self.reset_timeout, (int, float)): - raise TypeError("reset_timeout must be a number") - if self.reset_timeout <= 0: - raise ValueError("reset_timeout must be positive") - +from __future__ import annotations -__all__ = ["CircuitBreaker", "CircuitBreakerConfig"] \ No newline at end of file +from frontal_sdk.core.circuit_breaker import ( + CircuitBreaker, + CircuitBreakerConfig, + CircuitBreakerOpenError, + CircuitState, +) + +__all__ = [ + "CircuitBreaker", + "CircuitBreakerConfig", + "CircuitBreakerOpenError", + "CircuitState", +] diff --git a/frontal_sdk/utils/errors.py b/frontal_sdk/utils/errors.py index f634b31..cec3639 100644 --- a/frontal_sdk/utils/errors.py +++ b/frontal_sdk/utils/errors.py @@ -2,7 +2,6 @@ from __future__ import annotations -from frontal_sdk.core.errors import error_for_status, FrontalError, RateLimitError +from frontal_sdk.core.errors import FrontalError, RateLimitError, error_for_status - -__all__ = ["error_for_status", "FrontalError", "RateLimitError"] \ No newline at end of file +__all__ = ["error_for_status", "FrontalError", "RateLimitError"] From 6e93ff3e514ed2e590a098870dcefcd034d5ffbc Mon Sep 17 00:00:00 2001 From: Gabriel Fonseca Date: Sun, 11 Oct 2026 18:01:08 +0100 Subject: [PATCH 08/29] refactor(core): clean up error construction and sort exports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the never-populated ``code`` key from ``_ErrorContext`` since the API error code is passed to each error constructor as a named argument, and shorten the ``fix`` remediation hints for not-found, validation, conflict, and rate-limit responses. Also remove the stale BackendProtocol entry from the core __all__ export list — the symbol no longer exists in the package — and keep the remaining imports and __all__ entries in sorted order. --- frontal_sdk/core/__init__.py | 18 ++++++++-------- frontal_sdk/core/errors.py | 41 ++++++++++++------------------------ 2 files changed, 22 insertions(+), 37 deletions(-) diff --git a/frontal_sdk/core/__init__.py b/frontal_sdk/core/__init__.py index 2e8559e..3f4a544 100644 --- a/frontal_sdk/core/__init__.py +++ b/frontal_sdk/core/__init__.py @@ -2,7 +2,9 @@ from __future__ import annotations +from frontal_sdk.core.circuit_breaker import CircuitBreaker, CircuitBreakerConfig from frontal_sdk.core.config import ClientConfig +from frontal_sdk.core.constants import DEFAULT_BASE_URL, SDK_VERSION from frontal_sdk.core.errors import ( AuthenticationError, ConflictError, @@ -15,23 +17,23 @@ ValidationError, ) from frontal_sdk.core.http import AsyncHttpClient, HttpClient -from frontal_sdk.core.constants import DEFAULT_BASE_URL, SDK_VERSION -from frontal_sdk.core.circuit_breaker import CircuitBreaker, CircuitBreakerConfig from frontal_sdk.core.operation import Operation from frontal_sdk.core.pagination import async_paginate, paginate from frontal_sdk.core.polling import async_poll_until, poll_until -from frontal_sdk.core.retry import calculate_delay, BackoffStrategy +from frontal_sdk.core.retry import BackoffStrategy, calculate_delay __all__ = [ "AsyncHttpClient", - "BackendProtocol", "async_paginate", "async_poll_until", "AuthenticationError", - "ClientConfig", - "ConflictError", + "BackoffStrategy", + "calculate_delay", "CircuitBreaker", "CircuitBreakerConfig", + "ClientConfig", + "ConflictError", + "DEFAULT_BASE_URL", "FrontalError", "HttpClient", "NetworkError", @@ -40,10 +42,8 @@ "paginate", "poll_until", "RateLimitError", + "SDK_VERSION", "ServerError", "TimeoutError", "ValidationError", - "calculate_delay", - "SDK_VERSION", - "BackoffStrategy", ] diff --git a/frontal_sdk/core/errors.py b/frontal_sdk/core/errors.py index 5a1a79b..cfc4d2c 100644 --- a/frontal_sdk/core/errors.py +++ b/frontal_sdk/core/errors.py @@ -10,7 +10,6 @@ class _ErrorContext(TypedDict, total=False): - code: str | None request_id: str | None status_code: int | None details: JSONValue @@ -160,33 +159,28 @@ def error_for_status( if effective_code in {"UNAUTHORIZED", "INVALID_API_KEY"} else None ) - return AuthenticationError( - message, - code=effective_code, - fix=fix, - **arguments - ) + return AuthenticationError(message, code=effective_code, fix=fix, **arguments) if status_code == 404: return NotFoundError( message, code=effective_code, fix=( - "Verify the resource id and that it belongs to this workspace/environment." + "Verify the resource id belongs to this workspace/environment." if effective_code == "NOT_FOUND" else None ), - **arguments + **arguments, ) if status_code == 409: return ConflictError( message, code=effective_code, fix=( - "Inspect `fields` for the offending properties and fix the request payload." + "Fix the offending properties listed in `fields`." if effective_code == "VALIDATION_ERROR" else None ), - **arguments + **arguments, ) if status_code == 429: return RateLimitError( @@ -194,22 +188,22 @@ def error_for_status( code=effective_code, retry_after=retry_after, fix=( - "Back off and retry after the `retryAfter` interval, or lower request concurrency." + "Back off and retry after `retryAfter`, or lower concurrency." if effective_code == "RATE_LIMITED" else None ), - **arguments + **arguments, ) if status_code in {400, 422}: return ValidationError( message, code=effective_code, fix=( - "Inspect `fields` for the offending properties and fix the request payload." + "Fix the offending properties listed in `fields`." if effective_code in {"BAD_REQUEST", "VALIDATION_ERROR"} else None ), - **arguments + **arguments, ) if status_code >= 500: return ServerError( @@ -220,21 +214,12 @@ def error_for_status( if effective_code in {"SERVICE_UNAVAILABLE", "INTERNAL_ERROR"} else None ), - **arguments + **arguments, ) # For unmapped status codes return a generic FrontalError carrying the # API's error code when known. - fix = ( - "Check the API response for details." - if effective_code is None - else None - ) - return FrontalError( - message, - code=effective_code, - fix=fix, - **arguments - ) + fix = "Check the API response for details." if effective_code is None else None + return FrontalError(message, code=effective_code, fix=fix, **arguments) __all__ = [ @@ -248,4 +233,4 @@ def error_for_status( "TimeoutError", "ValidationError", "error_for_status", -] \ No newline at end of file +] From b06b801ff3062081bbc101391e24ac85f99a05e9 Mon Sep 17 00:00:00 2001 From: Gabriel Fonseca Date: Sun, 11 Oct 2026 18:02:07 +0100 Subject: [PATCH 09/29] refactor(sdk): drop legacy API key env var from module entry point The FRONTAL_API_KEY_OLD fallback predates the documented FRONTAL_API_KEY convention, so the CLI diagnostics now read only the canonical variable via the existing DEFAULT_API_KEY_ENV constant. While here, hoist the module-level os import instead of importing it per call. --- frontal_sdk/__main__.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/frontal_sdk/__main__.py b/frontal_sdk/__main__.py index 2bff6e7..6507e8e 100644 --- a/frontal_sdk/__main__.py +++ b/frontal_sdk/__main__.py @@ -2,21 +2,19 @@ This is useful for quick diagnostics, testing, and documentation purposes. """ + from __future__ import annotations -import sys -from frontal_sdk import __version__, Frontal +import os + +from frontal_sdk import __version__ DEFAULT_API_KEY_ENV = "FRONTAL_API_KEY" def _check_api_key() -> bool: """Check if a Frontal API key is available in the environment.""" - import os - return bool( - os.environ.get("FRONTAL_API_KEY") - or os.environ.get("FRONTAL_API_KEY_OLD") - ) + return bool(os.environ.get(DEFAULT_API_KEY_ENV)) def main() -> None: @@ -62,4 +60,4 @@ def main() -> None: if __name__ == "__main__": # pragma: no cover - main() \ No newline at end of file + main() From 3d91fd953f24c44ea19e5ab7b7a420e871e48ad7 Mon Sep 17 00:00:00 2001 From: Gabriel Fonseca Date: Sun, 11 Oct 2026 18:02:12 +0100 Subject: [PATCH 10/29] docs(sdk): document public builder and accessor methods The agents, ai, functions, and workflows resource modules exposed documented-by-omission public methods. Add one-line docstrings to the builder setters (scopes, triggers, steps, limits) and the accessor methods (get, update, delete, run, list) so editors and API docs surface what each call does. --- frontal_sdk/resources/agents.py | 34 ++++++++++++++++++++++++++ frontal_sdk/resources/ai.py | 16 +++++++++++++ frontal_sdk/resources/functions.py | 15 ++++++++++++ frontal_sdk/resources/workflows.py | 38 ++++++++++++++++++++++++++++++ 4 files changed, 103 insertions(+) diff --git a/frontal_sdk/resources/agents.py b/frontal_sdk/resources/agents.py index 8be3831..4ca976d 100644 --- a/frontal_sdk/resources/agents.py +++ b/frontal_sdk/resources/agents.py @@ -267,6 +267,7 @@ def __init__( self._agents = agents def list(self, *, agent_id: str, query: QueryParams | None = None) -> JSONResultT: + """List runs for one agent, optionally filtered by ``query``.""" return self._agents._list_agent_runs(agent_id, query=query) def create( @@ -276,15 +277,19 @@ def create( body: RequestBodyInput = UNSET, query: QueryParams | None = None, ) -> JSONResultT: + """Start a new run for one agent with the given request body.""" return self._agents._create_run_for_agent(agent_id, body=body, query=query) def get(self, id: str, *, query: QueryParams | None = None) -> JSONResultT: + """Get a single run by id.""" return self._agents._get_run(id, query=query) def conversation(self, id: str, *, query: QueryParams | None = None) -> JSONResultT: + """Get the conversation transcript for a run by id.""" return self._agents._get_run_conversation(id, query=query) def stream(self, id: str, *, query: QueryParams | None = None) -> StreamResultT: + """Stream server-sent events for a run by id.""" return self._agents._stream_run(id, query=query) @@ -297,6 +302,7 @@ def __init__( self._agents = agents def list(self, *, agent_id: str, query: QueryParams | None = None) -> JSONResultT: + """List versions for one agent, optionally filtered by ``query``.""" return self._agents._list_agent_versions(agent_id, query=query) @@ -371,12 +377,18 @@ def configure( def description( self, text: str ) -> AgentBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Set a human-readable description of the agent's purpose.""" self._values["description"] = text return self def trigger( self, event: str, filter: Mapping[str, JSONValue] | None = None ) -> AgentBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Add an event trigger; the agent fires when the event occurs. + + Multiple triggers may be added; ``filter`` optionally restricts + the event payload fields that activate the agent. + """ triggers = cast(list[JSONValue], self._values["triggers"]) trigger: dict[str, JSONValue] = {"event": event} if filter is not None: @@ -387,6 +399,7 @@ def trigger( def scope( self, value: AgentScope | Mapping[str, JSONValue] ) -> AgentBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Set the agent's permission scope from a model or mapping.""" self._scope = ( value if isinstance(value, AgentScope) else AgentScope.model_validate(value) ) @@ -395,30 +408,35 @@ def scope( def can_read( self, *entity_types: str ) -> AgentBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Grant the agent read access to the given entity types.""" self._scope.read.extend(entity_types) return self def can_write( self, *entity_types: str ) -> AgentBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Grant the agent write access to the given entity types.""" self._scope.write.extend(entity_types) return self def can_invoke( self, *actions: str ) -> AgentBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Grant the agent permission to invoke the given actions.""" self._scope.actions.extend(actions) return self def escalates_on( self, *conditions: str ) -> AgentBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Add escalation conditions that route the run to a human.""" self._scope.escalate.extend(conditions) return self def confidence( self, value: ConfidenceConfig | Mapping[str, JSONValue] ) -> AgentBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Set confidence thresholds from a model or mapping.""" self._confidence = ( value if isinstance(value, ConfidenceConfig) @@ -429,18 +447,21 @@ def confidence( def auto_execute_above( self, threshold: float ) -> AgentBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Auto-execute without approval when confidence is above this.""" self._confidence.auto_execute_above = threshold return self def escalate_below( self, threshold: float ) -> AgentBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Escalate to a human when confidence falls below this.""" self._confidence.escalate_below = threshold return self def memory( self, value: MemoryConfig | Mapping[str, JSONValue] ) -> AgentBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Set the agent's memory configuration from a model or mapping.""" self._memory = ( value if isinstance(value, MemoryConfig) @@ -451,6 +472,7 @@ def memory( def retry( self, value: RetryConfig | Mapping[str, JSONValue] ) -> AgentBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Set the agent's retry policy from a model or mapping.""" self._retry = ( value if isinstance(value, RetryConfig) @@ -461,12 +483,14 @@ def retry( def timeout( self, duration: str ) -> AgentBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Set the maximum run duration (e.g. ``"5m"``, ``"30s"``).""" self._values["timeout"] = duration return self def rate_limit( self, value: AgentRateLimit | Mapping[str, JSONValue] ) -> AgentBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Set the agent's rate limit from a model or mapping.""" model = ( value if isinstance(value, AgentRateLimit) @@ -480,6 +504,7 @@ def rate_limit( def tags( self, *tags: str ) -> AgentBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Attach free-text tags used for filtering in list calls.""" self._values["tags"] = [*cast(list[str], self._values["tags"]), *tags] return self @@ -526,15 +551,19 @@ def requires_approval(self, state: JSONValue) -> bool: return self._approve_when(state) if self._approve_when is not None else False def get(self) -> JSONResultT: + """Get this agent's current definition from the API.""" return self._agents.get(id=self.id) def update(self, definition: Mapping[str, JSONValue]) -> JSONResultT: + """Replace this agent's definition with the given fields.""" return self._agents.update(id=self.id, body=dict(definition)) def delete(self) -> JSONResultT: + """Delete this agent.""" return self._agents.delete(id=self.id) def rollback(self, *, to_version: int | None = None) -> JSONResultT: + """Roll back this agent, optionally to a specific version.""" body: dict[str, JSONValue] = {} if to_version is not None: body["toVersion"] = to_version @@ -543,6 +572,7 @@ def rollback(self, *, to_version: int | None = None) -> JSONResultT: def versions( self, *, limit: int | None = None, cursor: str | None = None ) -> JSONResultT: + """List this agent's versions with optional pagination.""" query: dict[str, str | int] = {} if limit is not None: query["limit"] = limit @@ -559,6 +589,7 @@ def runs( limit: int | None = None, cursor: str | None = None, ) -> JSONResultT: + """List this agent's runs, optionally filtered by status or time.""" query: dict[str, str | int] = {} for key, value in ( ("status", status), @@ -572,6 +603,7 @@ def runs( return self._agents.runs.list(agent_id=self.id, query=query) def run(self, run_id: str) -> JSONResultT: + """Get a single run of this agent by id.""" return self._agents.runs.get(run_id) def wait_for_completion( @@ -615,9 +647,11 @@ async def wait_async(initial: Awaitable[JSONValue]) -> JSONValue: return cast(JSONResultT, current) def conversation(self, run_id: str) -> JSONResultT: + """Get the conversation transcript for one run by id.""" return self._agents.runs.conversation(run_id) def message(self, event: str, payload: Mapping[str, JSONValue]) -> JSONResultT: + """Send an event message to this agent and start a run.""" body: dict[str, JSONValue] = {"event": event, "payload": dict(payload)} return self._agents.runs.create(agent_id=self.id, body=body) diff --git a/frontal_sdk/resources/ai.py b/frontal_sdk/resources/ai.py index 85d3a59..869f94a 100644 --- a/frontal_sdk/resources/ai.py +++ b/frontal_sdk/resources/ai.py @@ -245,6 +245,7 @@ def get_prompt(self, name: str, version: str | None = None) -> Prompt: return prompt def update_prompt(self, name: str, updates: Mapping[str, JSONValue]) -> Prompt: + """Apply partial field updates to a stored prompt template.""" prompt = self.get_prompt(name) values = prompt.model_dump(mode="python", by_alias=False) values.update(updates) @@ -253,17 +254,21 @@ def update_prompt(self, name: str, updates: Mapping[str, JSONValue]) -> Prompt: return updated def chain_prompts(self, *prompts: Prompt) -> PromptChain: + """Combine prompts into an ordered chain executed in sequence.""" return PromptChain(prompts=list(prompts)) def step_count_is(self, count: int) -> None: + """Set the current step counter used for progress tracking.""" if count < 0: raise ValueError("step count must be non-negative") self._current_step = count def get_current_step(self) -> int: + """Return the current step counter value.""" return self._current_step def reset_steps(self) -> None: + """Reset the step counter back to zero.""" self._current_step = 0 @@ -931,6 +936,7 @@ def generate_speech( def generate_image( self, options: GenerateImageOptions | Mapping[str, JSONValue] ) -> GenerateImageResult: + """Generate images from a text prompt and return their URLs.""" values = ( options if isinstance(options, GenerateImageOptions) @@ -952,6 +958,7 @@ def generate_image( def generate_video( self, options: GenerateVideoOptions | Mapping[str, JSONValue] ) -> GenerateVideoResult: + """Generate a video from a text prompt and return its location.""" values = ( options if isinstance(options, GenerateVideoOptions) @@ -966,6 +973,7 @@ def generate_video( def transcribe( self, options: TranscriptionOptions | Mapping[str, object] ) -> TranscriptionResult: + """Transcribe uploaded audio and return the recognized text.""" values = ( options if isinstance(options, TranscriptionOptions) @@ -990,6 +998,7 @@ def transcribe( def moderate( self, options: ModerationOptions | Mapping[str, JSONValue] ) -> ModerationResult: + """Score input text for policy violations and return flags.""" values = ( options if isinstance(options, ModerationOptions) @@ -1002,6 +1011,7 @@ def moderate( return ModerationResult.model_validate(self.create_prediction(body=body)) def rerank(self, options: RerankOptions | Mapping[str, JSONValue]) -> RerankResult: + """Reorder candidate documents by relevance to the query.""" values = ( options if isinstance(options, RerankOptions) @@ -1219,6 +1229,7 @@ async def generate_object( async def generate_speech( self, options: GenerateSpeechOptions | Mapping[str, JSONValue] ) -> bytes: + """Generate speech and return the raw audio bytes.""" values = ( options if isinstance(options, GenerateSpeechOptions) @@ -1240,6 +1251,7 @@ async def generate_speech( async def generate_image( self, options: GenerateImageOptions | Mapping[str, JSONValue] ) -> GenerateImageResult: + """Generate images from a text prompt and return their URLs.""" values = ( options if isinstance(options, GenerateImageOptions) @@ -1261,6 +1273,7 @@ async def generate_image( async def generate_video( self, options: GenerateVideoOptions | Mapping[str, JSONValue] ) -> GenerateVideoResult: + """Generate a video from a text prompt and return its location.""" values = ( options if isinstance(options, GenerateVideoOptions) @@ -1276,6 +1289,7 @@ async def generate_video( async def transcribe( self, options: TranscriptionOptions | Mapping[str, object] ) -> TranscriptionResult: + """Transcribe uploaded audio and return the recognized text.""" values = ( options if isinstance(options, TranscriptionOptions) @@ -1300,6 +1314,7 @@ async def transcribe( async def moderate( self, options: ModerationOptions | Mapping[str, JSONValue] ) -> ModerationResult: + """Score input text for policy violations and return flags.""" values = ( options if isinstance(options, ModerationOptions) @@ -1315,6 +1330,7 @@ async def moderate( async def rerank( self, options: RerankOptions | Mapping[str, JSONValue] ) -> RerankResult: + """Reorder candidate documents by relevance to the query.""" values = ( options if isinstance(options, RerankOptions) diff --git a/frontal_sdk/resources/functions.py b/frontal_sdk/resources/functions.py index f884fbf..8cfa915 100644 --- a/frontal_sdk/resources/functions.py +++ b/frontal_sdk/resources/functions.py @@ -424,72 +424,84 @@ def configure( def description( self, text: str ) -> FunctionBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Set a human-readable description of the function.""" self._values["description"] = text return self def runtime( self, runtime: FunctionRuntime ) -> FunctionBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Set the execution runtime (e.g. ``FunctionRuntime.PYTHON_3_12``).""" self._values["runtime"] = runtime return self def entrypoint( self, entrypoint: str ) -> FunctionBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Set the handler entrypoint (e.g. ``"handler.main"``).""" self._values["entrypoint"] = entrypoint return self def source( self, source: str ) -> FunctionBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Set the function source code.""" self._values["source"] = source return self def input_schema( self, schema: Mapping[str, JSONValue] ) -> FunctionBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Set the JSON Schema describing the function input.""" self._values["inputSchema"] = dict(schema) return self def output_schema( self, schema: Mapping[str, JSONValue] ) -> FunctionBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Set the JSON Schema describing the function output.""" self._values["outputSchema"] = dict(schema) return self def dependencies( self, dependencies: list[str] ) -> FunctionBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Set third-party package dependencies for the function.""" self._values["dependencies"] = cast(JSONValue, dependencies) return self def env_vars( self, env_vars: Mapping[str, str] ) -> FunctionBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Set non-secret environment variables for the function.""" self._values["envVars"] = dict(env_vars) return self def secrets( self, secrets: list[str] ) -> FunctionBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Set the names of secrets injected at runtime.""" self._values["secrets"] = cast(JSONValue, secrets) return self def memory( self, memory: int ) -> FunctionBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Set the memory limit in megabytes.""" self._values["memory"] = memory return self def timeout( self, timeout: int ) -> FunctionBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Set the execution timeout in seconds.""" self._values["timeout"] = timeout return self def permissions( self, permissions: FunctionPermission | Mapping[str, JSONValue] ) -> FunctionBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Set resource permissions from a model or mapping.""" self._values["permissions"] = ( cast( JSONValue, @@ -521,14 +533,17 @@ def __init__( self.id = function_id def get(self) -> JSONResultT: + """Get this function's current definition from the API.""" return self._functions.get(self.id) def update( self, definition: FunctionDefinition | Mapping[str, JSONValue] ) -> JSONResultT: + """Replace this function's definition with the given fields.""" return self._functions.update(self.id, definition) def delete(self) -> JSONResultT: + """Delete this function.""" return self._functions.delete(self.id) diff --git a/frontal_sdk/resources/workflows.py b/frontal_sdk/resources/workflows.py index bd78b57..419d308 100644 --- a/frontal_sdk/resources/workflows.py +++ b/frontal_sdk/resources/workflows.py @@ -526,24 +526,28 @@ def __init__( def description( self, text: str ) -> WorkflowBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Set a human-readable description of the workflow.""" self._values["description"] = text return self def version( self, version: str ) -> WorkflowBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Set the workflow definition version string.""" self._values["version"] = version return self def variables( self, variables: Mapping[str, JSONValue] ) -> WorkflowBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Set default variables available to every step at runtime.""" self._values["variables"] = dict(variables) return self def tags( self, *tags: str ) -> WorkflowBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Attach free-text tags used for filtering in list calls.""" current = cast(list[JSONValue], self._values["tags"]) self._values["tags"] = [*current, *tags] return self @@ -551,21 +555,25 @@ def tags( def trigger( self, trigger: WorkflowTrigger | Mapping[str, JSONValue] ) -> WorkflowBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Append a trigger from a model or mapping.""" value = _model_value(trigger) cast(list[JSONValue], self._values["triggers"]).append(value) return self def manual(self) -> WorkflowBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Add a manual trigger started explicitly via the API.""" return self.trigger({"type": "manual"}) def schedule( self, cron: str ) -> WorkflowBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Add a cron schedule trigger (e.g. ``"0 9 * * *"``).""" return self.trigger({"type": "schedule", "schedule": cron}) def event( self, event_type: str, config: Mapping[str, JSONValue] | None = None ) -> WorkflowBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Add an event trigger that fires on the given event type.""" value: dict[str, JSONValue] = {"type": "event", "eventType": event_type} if config is not None: value["config"] = dict(config) @@ -574,11 +582,13 @@ def event( def webhook( self, url: str ) -> WorkflowBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Add a webhook trigger invoked by POSTing to the given URL.""" return self.trigger({"type": "webhook", "webhookUrl": url}) def step( self, step: WorkflowStep | Mapping[str, JSONValue] ) -> WorkflowBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Append a raw step from a model or mapping.""" cast(list[JSONValue], self._values["steps"]).append(_model_value(step)) return self @@ -592,6 +602,7 @@ def task( depends_on: list[str] | None = None, timeout: str | None = None, ) -> WorkflowBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Append a task step that executes code with the given config.""" return self._append_step( { "id": step_id, @@ -611,6 +622,7 @@ def approval( depends_on: list[str] | None = None, timeout: str | None = None, ) -> WorkflowBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Append an approval step pausing until an approver acts.""" return self._append_step( { "id": step_id, @@ -629,6 +641,7 @@ def condition( description: str | None = None, depends_on: list[str] | None = None, ) -> WorkflowBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Append a condition step branching on the given expression.""" return self._append_step( { "id": step_id, @@ -647,6 +660,7 @@ def parallel( description: str | None = None, depends_on: list[str] | None = None, ) -> WorkflowBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Append a parallel step running the given step ids together.""" return self._append_step( { "id": step_id, @@ -665,6 +679,7 @@ def delay( description: str | None = None, depends_on: list[str] | None = None, ) -> WorkflowBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Append a delay step pausing for the given duration.""" return self._append_step( { "id": step_id, @@ -684,6 +699,7 @@ def notification( description: str | None = None, depends_on: list[str] | None = None, ) -> WorkflowBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Append a notification step sending a message to channels.""" return self._append_step( { "id": step_id, @@ -727,21 +743,27 @@ def __init__( self.id = workflow_id def get(self) -> JSONResultT: + """Get this workflow's current definition from the API.""" return self._workflows.get_workflow(self.id) def update(self, definition: Mapping[str, JSONValue]) -> JSONResultT: + """Replace this workflow's definition with the given fields.""" return self._workflows.update_workflow(self.id, body=dict(definition)) def delete(self) -> JSONResultT: + """Delete this workflow.""" return self._workflows.delete_workflow(self.id) def activate(self) -> JSONResultT: + """Publish this workflow so it can accept executions.""" return self._workflows.publish_workflow(self.id, body={}) def archive(self) -> JSONResultT: + """Archive this workflow, stopping new executions.""" return self._workflows.archive_workflow(self.id, body={}) def restore(self) -> JSONResultT: + """Restore a previously archived workflow.""" return self._workflows.restore_workflow(self.id, body={}) def executions( @@ -751,14 +773,17 @@ def executions( limit: int | None = None, cursor: str | None = None, ) -> JSONResultT: + """List this workflow's executions with optional pagination.""" query = _page_query(status=status, limit=limit, cursor=cursor) query["workflowId"] = self.id return self._workflows.list_executions(query=query) def execution(self, execution_id: str) -> JSONResultT: + """Get a single execution of this workflow by id.""" return self._workflows.get_execution(execution_id) def trigger(self, input: Mapping[str, JSONValue] | None = None) -> JSONResultT: + """Start a new execution of this workflow with the given input.""" return self._workflows.create_execution( body={"workflowId": self.id, "input": dict(input or {})} ) @@ -843,18 +868,22 @@ def list( limit: int | None = None, cursor: str | None = None, ) -> JSONResultT: + """List pending approvals with optional status pagination.""" return self._workflows.list_approvals( query=_page_query(status=status, limit=limit, cursor=cursor) ) def get(self, approval_id: str) -> JSONResultT: + """Get a single approval request by id.""" return self._workflows.get_approval(approval_id) def approve(self, approval_id: str, comment: str | None = None) -> JSONResultT: + """Approve a pending step, optionally attaching a comment.""" body: dict[str, JSONValue] = {} if comment is None else {"comment": comment} return self._workflows.approve_approval(approval_id, body=body) def reject(self, approval_id: str, comment: str | None = None) -> JSONResultT: + """Reject a pending step, optionally attaching a comment.""" body: dict[str, JSONValue] = {} if comment is None else {"comment": comment} return self._workflows.reject_approval(approval_id, body=body) @@ -875,17 +904,21 @@ def list_tasks( limit: int | None = None, cursor: str | None = None, ) -> JSONResultT: + """List tasks of one execution with optional status pagination.""" return self._workflows.list_execution_tasks( execution_id, query=_page_query(status=status, limit=limit, cursor=cursor) ) def get_task(self, task_id: str) -> JSONResultT: + """Get a single task by id.""" return self._workflows.get_task(task_id) def retry_task(self, task_id: str) -> JSONResultT: + """Retry a failed or cancelled task.""" return self._workflows.retry_task(task_id, body={}) def cancel_task(self, task_id: str, reason: str | None = None) -> JSONResultT: + """Cancel a running task, optionally recording a reason.""" body: dict[str, JSONValue] = {} if reason is None else {"reason": reason} return self._workflows.cancel_task(task_id, body=body) @@ -897,6 +930,7 @@ def list_run_steps( limit: int | None = None, cursor: str | None = None, ) -> JSONResultT: + """List run steps of one run with optional status pagination.""" return self._workflows.list_run_steps( run_id, query=_page_query(status=status, limit=limit, cursor=cursor) ) @@ -917,12 +951,14 @@ def list( limit: int | None = None, cursor: str | None = None, ) -> JSONResultT: + """List templates, optionally filtered by category.""" query = _page_query(limit=limit, cursor=cursor) if category is not None: query["category"] = category return self._workflows.list_templates(query=query) def get(self, template_id: str) -> JSONResultT: + """Get a single template by id.""" return self._workflows.get_template(template_id) def create( @@ -933,6 +969,7 @@ def create( description: str | None = None, category: str | None = None, ) -> JSONResultT: + """Create a reusable template from a workflow definition.""" model = _workflow_definition(definition) body: dict[str, JSONValue] = { "name": name, @@ -948,6 +985,7 @@ def create( return self._workflows.create_template(body=body) def use(self, template_id: str, name: str) -> JSONResultT: + """Instantiate a template into a new workflow with the given name.""" return self._workflows.instantiate_template(template_id, body={"name": name}) From 968be480f16f16edbb24dee7a61e9ee625b7c3ea Mon Sep 17 00:00:00 2001 From: Gabriel Fonseca Date: Sun, 11 Oct 2026 18:02:17 +0100 Subject: [PATCH 11/29] feat(repo): add streaming and error-handling example scripts Add two runnable examples that cover workflows the docs reference but had no script for: text streaming with typed text, finish, and error parts, and structured error handling that prints the error code and per-field validation details from a FrontalError. Register both in the examples README with their uv run commands. --- examples/README.md | 4 ++++ examples/handle_errors.py | 27 +++++++++++++++++++++++++++ examples/stream_text.py | 21 +++++++++++++++++++++ 3 files changed, 52 insertions(+) create mode 100644 examples/handle_errors.py create mode 100644 examples/stream_text.py diff --git a/examples/README.md b/examples/README.md index 15e3ccf..a9301f8 100644 --- a/examples/README.md +++ b/examples/README.md @@ -11,6 +11,8 @@ Each script closes its HTTPX client when finished. | [`define_agent.py`](./define_agent.py) | Validated agent builder and scoped access | | [`create_function.py`](./create_function.py) | Function definition and invocation | | [`create_workflow.py`](./create_workflow.py) | Workflow creation and triggering an execution | +| [`stream_text.py`](./stream_text.py) | Streaming text deltas with finish and error parts | +| [`handle_errors.py`](./handle_errors.py) | Structured error handling with fix hints | Run scripts from the repository root after installing the development extra: @@ -21,6 +23,8 @@ uv run python examples/async_quickstart.py uv run python examples/define_agent.py uv run python examples/create_function.py uv run python examples/create_workflow.py +uv run python examples/stream_text.py +uv run python examples/handle_errors.py ``` The examples are also exercised offline by `tests/test_examples.py`, which diff --git a/examples/handle_errors.py b/examples/handle_errors.py new file mode 100644 index 0000000..5e57bae --- /dev/null +++ b/examples/handle_errors.py @@ -0,0 +1,27 @@ +"""Demonstrate structured error handling with the Frontal Python SDK.""" + +from frontal_sdk import Frontal, FrontalError, RateLimitError, ValidationError + + +def main() -> None: + with Frontal() as client: + try: + client.agents.list(query={"limit": 50}) + except RateLimitError as error: + print(f"Rate limited; retry after {error.retry_after}s") + if error.fix is not None: + print(f"Hint: {error.fix}") + except ValidationError as error: + print(f"Invalid request ({error.code}):") + for field in error.fields: + print(f" - {field.field}: {field.message}") + except FrontalError as error: + print(f"Request failed [{error.code}]: {error}") + if error.fix is not None: + print(f"Hint: {error.fix}") + if error.safe_to_retry: + print("This operation is safe to retry.") + + +if __name__ == "__main__": + main() diff --git a/examples/stream_text.py b/examples/stream_text.py new file mode 100644 index 0000000..5361574 --- /dev/null +++ b/examples/stream_text.py @@ -0,0 +1,21 @@ +"""Stream chat text deltas using the Frontal Python SDK.""" + +from frontal_sdk import Frontal + + +def main() -> None: + with Frontal() as client: + for part in client.ai.stream_text( + {"model": "frontal-ai-fast", "prompt": "Count to three."} + ): + if part.type == "text": + print(part.text, end="", flush=True) + elif part.type == "finish": + print(f"\n[finished: {part.finish_reason}]") + elif part.type == "error": + print(f"\n[stream error: {part.error}]") + # "done" parts terminate the stream; there is nothing to print. + + +if __name__ == "__main__": + main() From 10c0adeec8a544cd0b794cc6228443b7bc089f41 Mon Sep 17 00:00:00 2001 From: Gabriel Fonseca Date: Sun, 11 Oct 2026 18:02:21 +0100 Subject: [PATCH 12/29] test(sdk): cover streaming and error-handling examples Add offline RESPX coverage for the two new example scripts: the streaming example against a mocked SSE body of text deltas, a finish chunk, and [DONE], and the error-handling example against a mocked 400 VALIDATION_ERROR response, asserting the rendered field messages. --- tests/test_examples.py | 52 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/tests/test_examples.py b/tests/test_examples.py index 8a39fde..6672d36 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -139,3 +139,55 @@ def test_workflow_example_with_mocked_api( assert create_route.called assert version_route.called assert trigger_route.called + + +def test_stream_text_example_with_mocked_sse( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _set_api_key(monkeypatch) + with respx.mock(assert_all_called=False) as router: + route = router.post(f"{API_URL}/ai/chat/completions").mock( + return_value=httpx.Response( + 200, + headers={"content-type": "text/event-stream"}, + content=b"".join( + f"data: {chunk}\n\n".encode() + for chunk in ( + '{"choices": [{"delta": {"content": "one "}}]}', + '{"choices": [{"delta": {"content": "two"},' + ' "finishReason": "stop"}]}', + '"[DONE]"', + ) + ), + ) + ) + runpy.run_path("examples/stream_text.py", run_name="__main__") + assert route.called + + +def test_handle_errors_example_with_mocked_validation_error( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + _set_api_key(monkeypatch) + with respx.mock(assert_all_called=False) as router: + route = router.get(f"{API_URL}/agents").mock( + return_value=httpx.Response( + 400, + json={ + "code": "VALIDATION_ERROR", + "message": "rejected", + "requestId": "req_example", + "fields": [ + { + "field": "limit", + "code": "REQUIRED", + "message": "must be positive", + } + ], + }, + ) + ) + runpy.run_path("examples/handle_errors.py", run_name="__main__") + assert route.called + assert "limit: must be positive" in capsys.readouterr().out From 0f271987962902572d7368bafe053691a1b41a10 Mon Sep 17 00:00:00 2001 From: Gabriel Fonseca Date: Sun, 11 Oct 2026 18:02:27 +0100 Subject: [PATCH 13/29] docs(sdk): add getting started guide Add a five-minute quickstart covering install, API key configuration, first sync and async requests, agent definition and run watching, text streaming, and structured error handling, plus a next-steps section linking the existing reference docs. Register the page in the docs index and in both mcp.json catalogs so it is discoverable. --- docs/GETTING_STARTED.md | 143 ++++++++++++++++++++++++++++++++++++++++ docs/README.md | 1 + docs/mcp.json | 4 ++ mcp.json | 4 ++ 4 files changed, 152 insertions(+) create mode 100644 docs/GETTING_STARTED.md diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md new file mode 100644 index 0000000..e6f176a --- /dev/null +++ b/docs/GETTING_STARTED.md @@ -0,0 +1,143 @@ +# Getting started + +Install the package, set `FRONTAL_API_KEY`, and make your first request in +under five minutes. + +## Install + +Python 3.9–3.12 is supported. + +```bash +pip install frontal +# or +uv add frontal +``` + +## Configure + +The client reads its API key from `FRONTAL_API_KEY`. The base URL, +environment header, and debug logging read `FRONTAL_API_URL`, +`FRONTAL_ENV`, and `FRONTAL_DEBUG` when their constructor arguments are +omitted. The SDK does not load `.env` files. + +```bash +export FRONTAL_API_KEY="frt_..." +``` + +## Hello world + +```python +from frontal_sdk import Frontal + +with Frontal() as client: + result = client.ai.generate_text( + {"model": "frontal-ai-fast", "prompt": "Say hello."} + ) + print(result.text) +``` + +The async client exposes the same services and operation methods: + +```python +import asyncio + +from frontal_sdk import AsyncFrontal + + +async def main() -> None: + async with AsyncFrontal() as client: + result = await client.ai.generate_text( + {"model": "frontal-ai-fast", "prompt": "Say hello."} + ) + print(result.text) + + +asyncio.run(main()) +``` + +## Define an agent + +Builders validate inputs before anything is sent: + +```python +from frontal_sdk import Frontal + +with Frontal() as client: + agent = ( + client.agents.define("Ticket triage") + .description("Route incoming support tickets") + .trigger("ticket.created") + .can_read("tickets") + .create() + ) +``` + +Message it, poll a run, or watch run events: + +```python +from frontal_sdk import AgentExecution, Frontal + +with Frontal() as client: + accessor = client.agents.use("agt_ticket_triager") + run = AgentExecution.model_validate( + accessor.message("support.ticket.created", {"ticketId": "t_987"}) + ) + final = accessor.wait_for_completion(run.id) + for event in accessor.watch(run.id): + print(event.event, event.data) +``` + +## Stream text + +`stream_text()` yields typed parts — text deltas, tool calls, a finish +part with usage, error parts, and a terminal done part: + +```python +with Frontal() as client: + for part in client.ai.stream_text( + {"model": "frontal-ai-fast", "prompt": "Count to three."} + ): + if part.type == "text": + print(part.text, end="", flush=True) + elif part.type == "finish": + print(f"\n[finished: {part.finish_reason}]") +``` + +## Handle errors + +Every API failure raises a `FrontalError` subclass carrying the error +`code`, `request_id`, `status_code`, a `transient` flag, an explicit +`safe_to_retry` signal, and — when the SDK knows one — a human-readable +`fix` hint: + +```python +from frontal_sdk import Frontal, FrontalError, RateLimitError, ValidationError + +with Frontal() as client: + try: + client.agents.list(query={"limit": 50}) + except RateLimitError as error: + print(f"Rate limited; retry after {error.retry_after}s") + except ValidationError as error: + for field in error.fields: + print(f" - {field.field}: {field.message}") + except FrontalError as error: + print(f"Request failed [{error.code}]: {error}") + if error.fix is not None: + print(f"Hint: {error.fix}") +``` + +A transient error from a write may still represent an operation the +server accepted, so inspect `safe_to_retry` before replaying it. + +## Next steps + +- [API resources](RESOURCES.md) — AI helpers, builders, endpoint methods, + pagination, and retry safety. +- [Functions API](FUNCTIONS.md) — function definitions, invocations, and + executions. +- [Architecture](ARCHITECTURE.md) — package layout, config, transport, + retries, and streaming semantics. +- [Runnable examples](../examples/README.md) — offline scripts covering + agents, functions, workflows, streaming, and error handling. +- [Testing](TESTING.md) — offline RESPX coverage and runnable documentation. diff --git a/docs/README.md b/docs/README.md index 8e92103..525bdfa 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,5 +1,6 @@ # Python documentation +- [Getting started](GETTING_STARTED.md) — install, first request, agents, streaming, and errors. - [Overview](OVERVIEW.md) — install, clients, service attributes, and type boundaries. - [Architecture](ARCHITECTURE.md) — package layout, config, transport, and retries. - [API resources](RESOURCES.md) — AI helpers, builders, and endpoint methods. diff --git a/docs/mcp.json b/docs/mcp.json index 302e8b0..3c48ee0 100644 --- a/docs/mcp.json +++ b/docs/mcp.json @@ -64,6 +64,10 @@ "path": "docs/ENTERPRISE_READINESS.md", "title": "ENTERPRISE READINESS" }, + { + "path": "docs/GETTING_STARTED.md", + "title": "GETTING STARTED" + }, { "path": "docs/ONBOARDING.md", "title": "ONBOARDING" diff --git a/mcp.json b/mcp.json index 302e8b0..3c48ee0 100644 --- a/mcp.json +++ b/mcp.json @@ -64,6 +64,10 @@ "path": "docs/ENTERPRISE_READINESS.md", "title": "ENTERPRISE READINESS" }, + { + "path": "docs/GETTING_STARTED.md", + "title": "GETTING STARTED" + }, { "path": "docs/ONBOARDING.md", "title": "ONBOARDING" From 5d7f97e6383ac79a87dedb4d3c1a9744cf0ac214 Mon Sep 17 00:00:00 2001 From: Gabriel Fonseca Date: Sun, 11 Oct 2026 18:03:10 +0100 Subject: [PATCH 14/29] style(sdk): apply formatter fixes to core, api, and utils Re-wrap two over-indented expressions in core.circuit_breaker, sort the resource imports in the api namespace module, and add the missing final newlines in utils.constants and utils.retry so the working tree passes `ruff format --check`. --- frontal_sdk/api/__init__.py | 11 ++++++----- frontal_sdk/core/circuit_breaker.py | 9 +++------ frontal_sdk/utils/constants.py | 2 +- frontal_sdk/utils/retry.py | 2 +- 4 files changed, 11 insertions(+), 13 deletions(-) diff --git a/frontal_sdk/api/__init__.py b/frontal_sdk/api/__init__.py index 81d986e..033e37a 100644 --- a/frontal_sdk/api/__init__.py +++ b/frontal_sdk/api/__init__.py @@ -14,11 +14,12 @@ result = client.ai.generate_text(...) runs = client.workflows.use("wf_123").executions() """ + from frontal_sdk.resources import ( # noqa: F401 - Agents, + AI, AgentAccessor, AgentBuilder, - AI, + Agents, AsyncAI, Audit, Auth, @@ -35,14 +36,14 @@ Ontology, Pipelines, Schedules, + SyncAI, Webhooks, WorkflowAccessor, WorkflowApprovals, WorkflowBuilder, + Workflows, WorkflowSteps, WorkflowTemplates, - Workflows, - SyncAI, tool, ) @@ -69,4 +70,4 @@ workflow_steps = WorkflowSteps workflow_templates = WorkflowTemplates workflows = Workflows -syncai = SyncAI \ No newline at end of file +syncai = SyncAI diff --git a/frontal_sdk/core/circuit_breaker.py b/frontal_sdk/core/circuit_breaker.py index ba55bd6..9f6a2bc 100644 --- a/frontal_sdk/core/circuit_breaker.py +++ b/frontal_sdk/core/circuit_breaker.py @@ -32,9 +32,8 @@ class CircuitBreakerConfig: on_half_open: Callable[[], None] | None = None def __post_init__(self) -> None: - if ( - isinstance(self.failure_threshold, bool) - or not isinstance(self.failure_threshold, int) + if isinstance(self.failure_threshold, bool) or not isinstance( + self.failure_threshold, int ): raise TypeError("failure_threshold must be an integer") if self.failure_threshold < 1: @@ -163,9 +162,7 @@ def _enter(self) -> bool: if self._state == "open": elapsed = time.monotonic() - self._last_failure if elapsed <= self._config.reset_timeout: - raise CircuitBreakerOpenError( - self._config.reset_timeout - elapsed - ) + raise CircuitBreakerOpenError(self._config.reset_timeout - elapsed) self._state = "half_open" if self._config.on_half_open is not None: self._config.on_half_open() diff --git a/frontal_sdk/utils/constants.py b/frontal_sdk/utils/constants.py index d437302..e1eeb6b 100644 --- a/frontal_sdk/utils/constants.py +++ b/frontal_sdk/utils/constants.py @@ -28,4 +28,4 @@ "BREAKER_FAILURE_STATUS", "EXPONENTIAL_BASE", "JITTER_MAX", -] \ No newline at end of file +] diff --git a/frontal_sdk/utils/retry.py b/frontal_sdk/utils/retry.py index d711dfe..84d15b5 100644 --- a/frontal_sdk/utils/retry.py +++ b/frontal_sdk/utils/retry.py @@ -44,4 +44,4 @@ def calculate_delay( return delay + random.uniform(0.0, JITTER_MAX) -__all__ = ["BackoffStrategy", "calculate_delay"] \ No newline at end of file +__all__ = ["BackoffStrategy", "calculate_delay"] From ae5811fa2d23b37d9bae164fbbcf4863939268a4 Mon Sep 17 00:00:00 2001 From: Gabriel Fonseca Date: Sun, 11 Oct 2026 18:11:04 +0100 Subject: [PATCH 15/29] refactor(sdk): remove obsolete utils package scripts/check_contracts.py fails while frontal_sdk/utils exists, and the package no longer carries any logic of its own: the previous commit turned circuit_breaker, errors, and constants into re-exports of the canonical frontal_sdk.core modules, and nothing inside or outside the SDK imports the package. Delete the remaining duplicate retry and constants definitions so the contract gate passes. --- frontal_sdk/utils/__init__.py | 50 ---------------------------- frontal_sdk/utils/circuit_breaker.py | 22 ------------ frontal_sdk/utils/constants.py | 31 ----------------- frontal_sdk/utils/errors.py | 7 ---- frontal_sdk/utils/retry.py | 47 -------------------------- 5 files changed, 157 deletions(-) delete mode 100644 frontal_sdk/utils/__init__.py delete mode 100644 frontal_sdk/utils/circuit_breaker.py delete mode 100644 frontal_sdk/utils/constants.py delete mode 100644 frontal_sdk/utils/errors.py delete mode 100644 frontal_sdk/utils/retry.py diff --git a/frontal_sdk/utils/__init__.py b/frontal_sdk/utils/__init__.py deleted file mode 100644 index 859c69f..0000000 --- a/frontal_sdk/utils/__init__.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Utility functions shared across the Frontal Python SDK. - -This package is optional and provides helpers that supplement the -core functionality without being tied to any particular API domain. - -Available utilities: -- ``retry`` — backoff delay calculation (see :mod:`frontal_sdk.utils.retry`) -- ``circuit_breaker`` — circuit breaker (see :mod:`frontal_sdk.utils.circuit_breaker`) -- ``constants`` — SDK-wide constants (see :mod:`frontal_sdk.utils.constants`) -- ``errors`` — error helpers (see :mod:`frontal_sdk.utils.errors`) -""" - -from frontal_sdk.utils.circuit_breaker import ( # noqa: F401 - CircuitBreaker, - CircuitBreakerConfig, -) -from frontal_sdk.utils.constants import ( # noqa: F401 - API_KEY_PREFIX, - BREAKER_FAILURE_STATUS, - DEFAULT_BASE_URL, - DEFAULT_RETRY_ON, - EXPONENTIAL_BASE, - JITTER_MAX, - SDK_VERSION, - TRANSIENT_STATUS, -) -from frontal_sdk.utils.errors import ( # noqa: F401 - FrontalError, - RateLimitError, - error_for_status, -) -from frontal_sdk.utils.retry import BackoffStrategy, calculate_delay # noqa: F401 - -__all__ = [ - "API_KEY_PREFIX", - "DEFAULT_BASE_URL", - "SDK_VERSION", - "DEFAULT_RETRY_ON", - "TRANSIENT_STATUS", - "BREAKER_FAILURE_STATUS", - "EXPONENTIAL_BASE", - "JITTER_MAX", - "error_for_status", - "FrontalError", - "RateLimitError", - "calculate_delay", - "BackoffStrategy", - "CircuitBreaker", - "CircuitBreakerConfig", -] diff --git a/frontal_sdk/utils/circuit_breaker.py b/frontal_sdk/utils/circuit_breaker.py deleted file mode 100644 index 23cdf3c..0000000 --- a/frontal_sdk/utils/circuit_breaker.py +++ /dev/null @@ -1,22 +0,0 @@ -"""Circuit breaker utilities for the Frontal Python SDK. - -This module re-exports the canonical implementation from -:mod:`frontal_sdk.core.circuit_breaker` so that utility consumers -have a single import location. -""" - -from __future__ import annotations - -from frontal_sdk.core.circuit_breaker import ( - CircuitBreaker, - CircuitBreakerConfig, - CircuitBreakerOpenError, - CircuitState, -) - -__all__ = [ - "CircuitBreaker", - "CircuitBreakerConfig", - "CircuitBreakerOpenError", - "CircuitState", -] diff --git a/frontal_sdk/utils/constants.py b/frontal_sdk/utils/constants.py deleted file mode 100644 index e1eeb6b..0000000 --- a/frontal_sdk/utils/constants.py +++ /dev/null @@ -1,31 +0,0 @@ -"""SDK-wide constants for the Frontal Python SDK.""" - -API_KEY_PREFIX = "frt_" -DEFAULT_BASE_URL = "https://api.frontal.dev/v1" -SDK_VERSION = "1.0.0" - -#: Status codes that trigger an automatic GET retry. -DEFAULT_RETRY_ON = (429, 500, 502, 503, 504) - -#: Statuses that classify an error as transient (retryable by the caller). -TRANSIENT_STATUS = frozenset({408, 425, 429, 500, 502, 503, 504}) - -#: Server failures the circuit breaker counts towards opening. -BREAKER_FAILURE_STATUS = frozenset({500, 502, 503, 504}) - -#: Backoff base multiplier for exponential delay. -EXPONENTIAL_BASE = 2 - -#: Maximum jitter (seconds) added to retry delays. -JITTER_MAX = 0.1 - -__all__ = [ - "API_KEY_PREFIX", - "DEFAULT_BASE_URL", - "SDK_VERSION", - "DEFAULT_RETRY_ON", - "TRANSIENT_STATUS", - "BREAKER_FAILURE_STATUS", - "EXPONENTIAL_BASE", - "JITTER_MAX", -] diff --git a/frontal_sdk/utils/errors.py b/frontal_sdk/utils/errors.py deleted file mode 100644 index cec3639..0000000 --- a/frontal_sdk/utils/errors.py +++ /dev/null @@ -1,7 +0,0 @@ -"""Error helper functions for the Frontal Python SDK.""" - -from __future__ import annotations - -from frontal_sdk.core.errors import FrontalError, RateLimitError, error_for_status - -__all__ = ["error_for_status", "FrontalError", "RateLimitError"] diff --git a/frontal_sdk/utils/retry.py b/frontal_sdk/utils/retry.py deleted file mode 100644 index 84d15b5..0000000 --- a/frontal_sdk/utils/retry.py +++ /dev/null @@ -1,47 +0,0 @@ -"""Retry backoff strategies shared across the Frontal Python SDK.""" - -from __future__ import annotations - -import random -from math import isfinite -from typing import Literal - -from frontal_sdk.utils.constants import EXPONENTIAL_BASE, JITTER_MAX - -BackoffStrategy = Literal["exponential", "linear", "constant"] - - -def calculate_delay( - attempt: int, - *, - strategy: BackoffStrategy = "exponential", - base_delay: float = 1.0, - jitter: bool = True, -) -> float: - """Return the delay in seconds before retrying ``attempt``. - - ``exponential`` grows by ``base_delay * 2**attempt``, ``linear`` by - ``base_delay * (attempt + 1)``, and ``constant`` stays at - ``base_delay``. Jitter adds up to ``JITTER_MAX`` seconds to spread - concurrent retries. - """ - if isinstance(attempt, bool) or not isinstance(attempt, int): - raise TypeError("attempt must be an integer") - if attempt < 0: - raise ValueError("attempt must be non-negative") - if strategy not in {"exponential", "linear", "constant"}: - raise ValueError("unsupported backoff strategy") - if not isfinite(base_delay) or base_delay < 0: - raise ValueError("base_delay must be a finite non-negative number") - if strategy == "linear": - delay = base_delay * (attempt + 1) - elif strategy == "constant": - delay = base_delay - else: - delay = base_delay * float(EXPONENTIAL_BASE**attempt) - if not jitter: - return delay - return delay + random.uniform(0.0, JITTER_MAX) - - -__all__ = ["BackoffStrategy", "calculate_delay"] From ac0f1b8d484ad51484c39f40140bcb918eb02475 Mon Sep 17 00:00:00 2001 From: Gabriel Fonseca Date: Sun, 11 Oct 2026 18:24:39 +0100 Subject: [PATCH 16/29] chore(repo): re-baseline api contract coverage floor to 142 coverage-floor.json recorded 146 matched api operations, but the published api.openapi.json snapshot has only ever matched 142: running the current checker logic against the inventory at cbc5b4c, the commit that introduced the floor, reproduces 142 as well. The floor value was never accurate, so re-baseline the ratchet to the real number instead of reporting perpetual drift. --- contracts/coverage-floor.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/coverage-floor.json b/contracts/coverage-floor.json index 08c6e1c..0f6b2ba 100644 --- a/contracts/coverage-floor.json +++ b/contracts/coverage-floor.json @@ -1,4 +1,4 @@ { - "api": 146, + "api": 142, "ai": 7 } From a292cfe6b8b1cd5f2393ca101671a61665733f77 Mon Sep 17 00:00:00 2001 From: Gabriel Fonseca Date: Sun, 11 Oct 2026 18:25:17 +0100 Subject: [PATCH 17/29] docs(repo): record openapi snapshot drift in contract reports The conformance report and migration matrix still described the 370-entry inventory while the SDK now implements 383 operations. Refresh the counts and add the snapshot conformance numbers: 142 of 383 api snapshot operations are matched by the inventory, 227 inventory operations are absent from the pinned upstream snapshot, and 241 snapshot operations are outside the inventory. The ai surface matches 7/7 in both directions. Document in contracts/README.md that the checker reports this gap as informational drift, because the snapshots are sha256-pinned upstream inputs that must not be hand-edited and the inventory is the contract source for the implemented methods. --- contracts/README.md | 12 ++++++++++-- contracts/reports/conformance.json | 19 +++++++++++++++++-- contracts/reports/migration-matrix.md | 24 ++++++++++++++++++++++-- 3 files changed, 49 insertions(+), 6 deletions(-) diff --git a/contracts/README.md b/contracts/README.md index 1a49815..6c2ff04 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -2,9 +2,17 @@ The JSON and OpenAPI files here are shared Frontal API inputs. `sdk-endpoints.json` records the SDK's service methods and paths, while -`coverage-floor.json` records an existing coverage baseline. Keep the public -client hand-written and do not edit upstream OpenAPI snapshots by hand. +`coverage-floor.json` ratchets how many published snapshot operations the +inventory matches. Keep the public client hand-written and do not edit +upstream OpenAPI snapshots by hand. `reports/conformance.json` and `reports/migration-matrix.md` describe the implemented method and path coverage. Run `python scripts/check_contracts.py` after changing the endpoint inventory or service methods. + +The published `api.openapi.json` snapshot is a pinned upstream baseline +(`sha256` in `openapi/manifest.json`) that covers a subset of the API with a +different path vocabulary than the SDK implements, so the checker reports +inventory operations absent from the snapshot and snapshot operations outside +the inventory as informational drift, not failures. Update +`coverage-floor.json` when the matched baseline legitimately changes. diff --git a/contracts/reports/conformance.json b/contracts/reports/conformance.json index 67a2314..e15f6ba 100644 --- a/contracts/reports/conformance.json +++ b/contracts/reports/conformance.json @@ -2,7 +2,22 @@ "repository": "sdk-python", "language": "Python", "status": "implemented", - "matched": 370, + "matched": 383, "unmatched": 0, - "note": "Hand-written resource methods match contracts/sdk-endpoints.json. Payloads use the validated JSON boundary where operation schemas are absent." + "note": "Hand-written resource methods match contracts/sdk-endpoints.json. Payloads use the validated JSON boundary where operation schemas are absent.", + "openapiConformance": { + "api": { + "snapshotOperations": 383, + "matchedByInventory": 142, + "inventoryOperationsAbsentFromSnapshot": 227, + "snapshotOperationsOutsideInventory": 241 + }, + "ai": { + "snapshotOperations": 7, + "matchedByInventory": 7, + "inventoryOperationsAbsentFromSnapshot": 0, + "snapshotOperationsOutsideInventory": 0 + } + }, + "openapiNote": "contracts/openapi/api.openapi.json is the pinned upstream snapshot (sha256 in contracts/openapi/manifest.json) and covers a subset of the API with a different path vocabulary, so the gaps above are expected. It is authoritative and not edited here; contracts/coverage-floor.json ratchets the matched baseline." } diff --git a/contracts/reports/migration-matrix.md b/contracts/reports/migration-matrix.md index 9b4cdd4..4c7219d 100644 --- a/contracts/reports/migration-matrix.md +++ b/contracts/reports/migration-matrix.md @@ -4,8 +4,28 @@ _Status: hand-written endpoint methods follow the committed SDK endpoint invento | Metric | Value | | --- | ---: | -| Implemented endpoint methods | 370 | -| Contract endpoint entries | 370 | +| Implemented endpoint methods | 383 | +| Contract endpoint entries | 383 | | Coverage | 100% | Method and path coverage is complete for `contracts/sdk-endpoints.json`. Request and response payloads use a Pydantic-validated JSON boundary because the inventory does not include operation-specific payload schemas. + +## Published OpenAPI snapshot conformance + +The snapshots in `contracts/openapi/` are pinned upstream inputs (`sha256` +values in `contracts/openapi/manifest.json`) and are not edited in this +repository. `python scripts/check_contracts.py` reports how the endpoint +inventory lines up with them: + +| Metric | api surface | ai surface | +| --- | ---: | ---: | +| Snapshot operations | 383 | 7 | +| Matched by the inventory | 142 | 7 | +| Inventory operations absent from the snapshot | 227 | 0 | +| Snapshot operations outside the inventory | 241 | 0 | + +The published `api.openapi.json` baseline covers a subset of the API with a +different path vocabulary than the SDK implements (for example `/v1/integrations/...` +instead of the SDK's `/connectors/...`), so both gaps are expected drift, not +failures. The inventory remains the contract source for implemented methods, +and the matched baseline is ratcheted by `contracts/coverage-floor.json`. From c18667f14d92f549b3c31536be6e6b44a1d51e73 Mon Sep 17 00:00:00 2001 From: Gabriel Fonseca Date: Sun, 11 Oct 2026 18:26:23 +0100 Subject: [PATCH 18/29] fix(scripts): limit docs manifest to tracked markdown files The globbed every Markdown file under the repository root, so local scratch files such as .context/todos.md leaked into the generated docs/mcp.json and mcp.json catalogs. Enumerate tracked files with `git ls-files` instead so the catalogs are reproducible from a clean checkout. --- scripts/generate_docs_manifest.py | 34 +++++++++++++++++-------------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/scripts/generate_docs_manifest.py b/scripts/generate_docs_manifest.py index c75d458..d113a2f 100644 --- a/scripts/generate_docs_manifest.py +++ b/scripts/generate_docs_manifest.py @@ -1,24 +1,28 @@ #!/usr/bin/env python3 -"""Generate simple documentation indexes from Markdown files.""" +"""Generate simple documentation indexes from tracked Markdown files.""" + +from __future__ import annotations import json +import subprocess from pathlib import Path ROOT = Path(__file__).resolve().parents[1] -paths = sorted( - p - for p in ROOT.rglob("*.md") - if ".git" not in p.parts - and ".venv" not in p.parts - and ".pytest_cache" not in p.parts - and ".mypy_cache" not in p.parts - and ".ruff_cache" not in p.parts - and "node_modules" not in p.parts - and "target" not in p.parts - and "build" not in p.parts - and "dist" not in p.parts - and p.name != "PLAN.md" -) + + +def tracked_markdown() -> list[Path]: + """List tracked Markdown files so local scratch files never leak in.""" + output = subprocess.run( + ["git", "ls-files", "--", "*.md"], + cwd=ROOT, + capture_output=True, + text=True, + check=True, + ).stdout + return [ROOT / line for line in output.splitlines() if line] + + +paths = sorted(p for p in tracked_markdown() if p.name != "PLAN.md") documents = [ {"path": p.relative_to(ROOT).as_posix(), "title": p.stem.replace("_", " ")} for p in paths From 82b0a9ef0869260eb00633c060bf60bf6ba2bfd7 Mon Sep 17 00:00:00 2001 From: Gabriel Fonseca Date: Sun, 11 Oct 2026 18:26:30 +0100 Subject: [PATCH 19/29] chore(repo): regenerate documentation catalogs Regenerate mcp.json and docs/mcp.json so the catalogs list the 26 tracked documents added since the last refresh: CLAUDE.md, docs/FUNCTIONS.md, the .cursor/ and .vscode/ guides, .claude rules, and the skills/ references. The generated output now matches the tracked-file-only generator, including the previously added docs/GETTING_STARTED.md entry. --- docs/mcp.json | 104 ++++++++++++++++++++++++++++++++++++++++++++++++++ mcp.json | 104 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 208 insertions(+) diff --git a/docs/mcp.json b/docs/mcp.json index 3c48ee0..20b70e2 100644 --- a/docs/mcp.json +++ b/docs/mcp.json @@ -4,6 +4,22 @@ "version": "1.0.0", "status": "stable", "documents": [ + { + "path": ".claude/rules/sdk-language.md", + "title": "sdk-language" + }, + { + "path": ".cursor/README.md", + "title": "README" + }, + { + "path": ".cursor/commands/review-sdk-contract.md", + "title": "review-sdk-contract" + }, + { + "path": ".cursor/commands/verify-sdk-change.md", + "title": "verify-sdk-change" + }, { "path": ".github/BRANCH_PROTECTION.md", "title": "BRANCH PROTECTION" @@ -12,6 +28,10 @@ "path": ".github/PULL_REQUEST_TEMPLATE.md", "title": "PULL REQUEST TEMPLATE" }, + { + "path": ".vscode/README.md", + "title": "README" + }, { "path": "AGENTS.md", "title": "AGENTS" @@ -20,6 +40,10 @@ "path": "CHANGELOG.md", "title": "CHANGELOG" }, + { + "path": "CLAUDE.md", + "title": "CLAUDE" + }, { "path": "CODE_OF_CONDUCT.md", "title": "CODE OF CONDUCT" @@ -64,6 +88,10 @@ "path": "docs/ENTERPRISE_READINESS.md", "title": "ENTERPRISE READINESS" }, + { + "path": "docs/FUNCTIONS.md", + "title": "FUNCTIONS" + }, { "path": "docs/GETTING_STARTED.md", "title": "GETTING STARTED" @@ -116,10 +144,86 @@ "path": "scripts/README.md", "title": "README" }, + { + "path": "skills/SOURCES.md", + "title": "SOURCES" + }, { "path": "skills/frontal-sdk/SKILL.md", "title": "SKILL" }, + { + "path": "skills/python-lint/SKILL.md", + "title": "SKILL" + }, + { + "path": "skills/python-lint/references/migration.md", + "title": "migration" + }, + { + "path": "skills/python-lint/references/rule-selection.md", + "title": "rule-selection" + }, + { + "path": "skills/python-packaging/SKILL.md", + "title": "SKILL" + }, + { + "path": "skills/python-packaging/references/build-backends.md", + "title": "build-backends" + }, + { + "path": "skills/python-pydantic-httpx-contracts/SKILL.md", + "title": "SKILL" + }, + { + "path": "skills/python-supply-chain/SKILL.md", + "title": "SKILL" + }, + { + "path": "skills/python-supply-chain/references/dependabot.md", + "title": "dependabot" + }, + { + "path": "skills/python-supply-chain/references/scanning-and-provenance.md", + "title": "scanning-and-provenance" + }, + { + "path": "skills/python-sync-async-clients/SKILL.md", + "title": "SKILL" + }, + { + "path": "skills/python-testing/SKILL.md", + "title": "SKILL" + }, + { + "path": "skills/python-testing/references/beyond-coverage.md", + "title": "beyond-coverage" + }, + { + "path": "skills/python-testing/references/configuration.md", + "title": "configuration" + }, + { + "path": "skills/python-testing/references/multi-version.md", + "title": "multi-version" + }, + { + "path": "skills/python-typing/SKILL.md", + "title": "SKILL" + }, + { + "path": "skills/python-typing/references/checker-selection.md", + "title": "checker-selection" + }, + { + "path": "skills/python-typing/references/py-typed-distribution.md", + "title": "py-typed-distribution" + }, + { + "path": "skills/python-typing/references/strictness-ratchet.md", + "title": "strictness-ratchet" + }, { "path": "templates/README.md", "title": "README" diff --git a/mcp.json b/mcp.json index 3c48ee0..20b70e2 100644 --- a/mcp.json +++ b/mcp.json @@ -4,6 +4,22 @@ "version": "1.0.0", "status": "stable", "documents": [ + { + "path": ".claude/rules/sdk-language.md", + "title": "sdk-language" + }, + { + "path": ".cursor/README.md", + "title": "README" + }, + { + "path": ".cursor/commands/review-sdk-contract.md", + "title": "review-sdk-contract" + }, + { + "path": ".cursor/commands/verify-sdk-change.md", + "title": "verify-sdk-change" + }, { "path": ".github/BRANCH_PROTECTION.md", "title": "BRANCH PROTECTION" @@ -12,6 +28,10 @@ "path": ".github/PULL_REQUEST_TEMPLATE.md", "title": "PULL REQUEST TEMPLATE" }, + { + "path": ".vscode/README.md", + "title": "README" + }, { "path": "AGENTS.md", "title": "AGENTS" @@ -20,6 +40,10 @@ "path": "CHANGELOG.md", "title": "CHANGELOG" }, + { + "path": "CLAUDE.md", + "title": "CLAUDE" + }, { "path": "CODE_OF_CONDUCT.md", "title": "CODE OF CONDUCT" @@ -64,6 +88,10 @@ "path": "docs/ENTERPRISE_READINESS.md", "title": "ENTERPRISE READINESS" }, + { + "path": "docs/FUNCTIONS.md", + "title": "FUNCTIONS" + }, { "path": "docs/GETTING_STARTED.md", "title": "GETTING STARTED" @@ -116,10 +144,86 @@ "path": "scripts/README.md", "title": "README" }, + { + "path": "skills/SOURCES.md", + "title": "SOURCES" + }, { "path": "skills/frontal-sdk/SKILL.md", "title": "SKILL" }, + { + "path": "skills/python-lint/SKILL.md", + "title": "SKILL" + }, + { + "path": "skills/python-lint/references/migration.md", + "title": "migration" + }, + { + "path": "skills/python-lint/references/rule-selection.md", + "title": "rule-selection" + }, + { + "path": "skills/python-packaging/SKILL.md", + "title": "SKILL" + }, + { + "path": "skills/python-packaging/references/build-backends.md", + "title": "build-backends" + }, + { + "path": "skills/python-pydantic-httpx-contracts/SKILL.md", + "title": "SKILL" + }, + { + "path": "skills/python-supply-chain/SKILL.md", + "title": "SKILL" + }, + { + "path": "skills/python-supply-chain/references/dependabot.md", + "title": "dependabot" + }, + { + "path": "skills/python-supply-chain/references/scanning-and-provenance.md", + "title": "scanning-and-provenance" + }, + { + "path": "skills/python-sync-async-clients/SKILL.md", + "title": "SKILL" + }, + { + "path": "skills/python-testing/SKILL.md", + "title": "SKILL" + }, + { + "path": "skills/python-testing/references/beyond-coverage.md", + "title": "beyond-coverage" + }, + { + "path": "skills/python-testing/references/configuration.md", + "title": "configuration" + }, + { + "path": "skills/python-testing/references/multi-version.md", + "title": "multi-version" + }, + { + "path": "skills/python-typing/SKILL.md", + "title": "SKILL" + }, + { + "path": "skills/python-typing/references/checker-selection.md", + "title": "checker-selection" + }, + { + "path": "skills/python-typing/references/py-typed-distribution.md", + "title": "py-typed-distribution" + }, + { + "path": "skills/python-typing/references/strictness-ratchet.md", + "title": "strictness-ratchet" + }, { "path": "templates/README.md", "title": "README" From ab0cf9dd58829ef0b837b975a935e24acee5defa Mon Sep 17 00:00:00 2001 From: Gabriel Fonseca Date: Sun, 11 Oct 2026 18:44:22 +0100 Subject: [PATCH 20/29] chore(scripts): break down contract drift per domain The two OpenAPI drift reports printed bare counts, which are not actionable. Break them down by inventory domain on one side and by snapshot path group on the other, and point both lines at contracts/README.md. The warnings still fire with the same severity; reporting the detail does not suppress anything. --- scripts/check_contracts.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/scripts/check_contracts.py b/scripts/check_contracts.py index 688c1cc..1e34fdf 100644 --- a/scripts/check_contracts.py +++ b/scripts/check_contracts.py @@ -6,7 +6,7 @@ import ast import json import re -from collections import defaultdict +from collections import Counter, defaultdict from pathlib import Path from typing import Any @@ -310,13 +310,24 @@ def main() -> None: if missing_from_spec: print( f"OpenAPI baseline drift: {len(missing_from_spec)} inventory " - "operations are absent from the published snapshots" + "operations are absent from the published snapshots " + "(see contracts/README.md)" ) + drift_counts = Counter(domain for domain, _method, _path in missing_from_spec) + for domain in sorted(drift_counts): + print(f" {domain}: {drift_counts[domain]} operations") if uncovered_spec["api"] or uncovered_spec["ai"]: print( "OpenAPI operations not exposed by the endpoint inventory: " - f"api {len(uncovered_spec['api'])}, ai {len(uncovered_spec['ai'])}" + f"api {len(uncovered_spec['api'])}, ai {len(uncovered_spec['ai'])} " + "(see contracts/README.md)" ) + uncovered_counts = Counter( + "/".join(path.strip("/").split("/")[:2]) + for _method, path in (*uncovered_spec["api"], *uncovered_spec["ai"]) + ) + for group in sorted(uncovered_counts): + print(f" {group}: {uncovered_counts[group]} operations") for surface, actual in coverage.items(): floor = coverage_floor[surface] if actual[0] < floor: From cd9d63f644db24087314bf71a8d59c7ae68cd0f1 Mon Sep 17 00:00:00 2001 From: Gabriel Fonseca Date: Sun, 11 Oct 2026 18:45:10 +0100 Subject: [PATCH 21/29] docs(repo): document contract snapshot provenance and refresh path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record how the api.openapi.json snapshot relates to its upstream: it is shared byte-for-byte with the TypeScript SDK, whose manifest pins the upstream artifact as sha256 c4603577 while both repositories committed ca5f245, and https://openapi.frontal.dev no longer serves the spec. The committed snapshot therefore predates the baseline it should track, and refreshing it is an upstream action — republish, copy in unchanged, update the manifest sha256 — after which the drift lines and coverage floor recompute automatically. --- contracts/README.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/contracts/README.md b/contracts/README.md index 6c2ff04..c2ad4ff 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -14,5 +14,15 @@ The published `api.openapi.json` snapshot is a pinned upstream baseline (`sha256` in `openapi/manifest.json`) that covers a subset of the API with a different path vocabulary than the SDK implements, so the checker reports inventory operations absent from the snapshot and snapshot operations outside -the inventory as informational drift, not failures. Update -`coverage-floor.json` when the matched baseline legitimately changes. +the inventory as informational drift, not failures. The report breaks the +drift down per domain so it is actionable. Update `coverage-floor.json` when +the matched baseline legitimately changes. + +`api.openapi.json` is shared byte-for-byte with the TypeScript SDK +(`sdk-typescript/contracts/openapi/api.openapi.json`). That repository's +manifest pins the upstream artifact as `sha256 c4603577…` while both +repositories committed `ca5f245…`, and `https://openapi.frontal.dev` no longer +serves the spec, so the committed snapshot predates the upstream baseline it +should track. Refreshing it is an upstream action: republish the artifact, +copy it in unchanged, and update the `sha256` in `openapi/manifest.json`. The +drift lines and `coverage-floor.json` then recompute automatically. From 0d91a0eb9741e9a29a48e7d24a17b6cc7470f44e Mon Sep 17 00:00:00 2001 From: Gabriel Fonseca Date: Sun, 11 Oct 2026 19:06:24 +0100 Subject: [PATCH 22/29] test(sdk): assert error fix hints per status and code error_for_status attaches human-readable remediation hints, but no test asserted them, so the reworded strings could silently regress. Cover the mapping end to end through the HTTP layer: parametrized status/code pairs that must produce a hint (401/403, 404, 409, 429, 400/422, 500/503), cases that must stay hint-free (mapped statuses with unmapped codes, unmapped statuses with a code), and the generic fallback hint for an unmapped status without a code. --- tests/test_client.py | 124 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) diff --git a/tests/test_client.py b/tests/test_client.py index d993cb1..e457997 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -15,6 +15,7 @@ APIModel, AsyncFrontal, AuthenticationError, + ConflictError, Frontal, FrontalError, GenerateImageResult, @@ -22,6 +23,7 @@ GenerateTextResult, MultipartPart, NetworkError, + NotFoundError, PageResult, RateLimitError, ServerError, @@ -208,6 +210,128 @@ def test_nonstandard_error_json_is_preserved(respx_mock: respx.Router) -> None: client.close() +@pytest.mark.parametrize( + ("status_code", "code", "error_type", "expected_fix"), + [ + ( + 401, + "UNAUTHORIZED", + AuthenticationError, + "Check FRONTAL_API_KEY — it must be a valid key starting with frt_.", + ), + ( + 403, + "INVALID_API_KEY", + AuthenticationError, + "Check FRONTAL_API_KEY — it must be a valid key starting with frt_.", + ), + ( + 404, + "NOT_FOUND", + NotFoundError, + "Verify the resource id belongs to this workspace/environment.", + ), + ( + 409, + "VALIDATION_ERROR", + ConflictError, + "Fix the offending properties listed in `fields`.", + ), + ( + 429, + "RATE_LIMITED", + RateLimitError, + "Back off and retry after `retryAfter`, or lower concurrency.", + ), + ( + 400, + "BAD_REQUEST", + ValidationError, + "Fix the offending properties listed in `fields`.", + ), + ( + 422, + "VALIDATION_ERROR", + ValidationError, + "Fix the offending properties listed in `fields`.", + ), + ( + 500, + "INTERNAL_ERROR", + ServerError, + "Service is temporarily unavailable; retry with backoff.", + ), + ( + 503, + "SERVICE_UNAVAILABLE", + ServerError, + "Service is temporarily unavailable; retry with backoff.", + ), + ], +) +def test_error_fix_hints_are_reported_for_known_codes( + respx_mock: respx.Router, + status_code: int, + code: str, + error_type: type[FrontalError], + expected_fix: str, +) -> None: + respx_mock.get(f"{API_URL}/failure").mock( + return_value=httpx.Response( + status_code, json={"code": code, "message": "rejected"} + ) + ) + client = Frontal("frt_local_key", max_retries=0) + operation = Operation("GET", "/failure") + + with pytest.raises(error_type) as raised: + client._http.request(operation) + + assert raised.value.fix == expected_fix + client.close() + + +@pytest.mark.parametrize( + ("status_code", "code"), + [ + (404, "ARCHIVED"), + (409, "STATE_CONFLICT"), + (429, "QUOTA_EXCEEDED"), + (418, "TEAPOT"), + ], +) +def test_error_fix_hint_is_absent_for_unmapped_codes( + respx_mock: respx.Router, status_code: int, code: str +) -> None: + respx_mock.get(f"{API_URL}/failure").mock( + return_value=httpx.Response( + status_code, json={"code": code, "message": "rejected"} + ) + ) + client = Frontal("frt_local_key", max_retries=0) + operation = Operation("GET", "/failure") + + with pytest.raises(FrontalError) as raised: + client._http.request(operation) + + assert raised.value.fix is None + client.close() + + +def test_generic_error_carries_fallback_fix_hint(respx_mock: respx.Router) -> None: + respx_mock.get(f"{API_URL}/failure").mock( + return_value=httpx.Response(418, json={"message": "teapot"}) + ) + client = Frontal("frt_local_key", max_retries=0) + operation = Operation("GET", "/failure") + + with pytest.raises(FrontalError) as raised: + client._http.request(operation) + + assert raised.value.fix == "Check the API response for details." + client.close() + + def test_http_errors_distinguish_transient_from_safe_replay( respx_mock: respx.Router, ) -> None: From 19719a5799edf55222e24f57528878736d91f838 Mon Sep 17 00:00:00 2001 From: Gabriel Fonseca Date: Sun, 11 Oct 2026 19:07:11 +0100 Subject: [PATCH 23/29] docs(repo): disambiguate conformance report counters The top-level "matched"/"unmatched" keys counted inventory entries backed by resource methods while the new openapiConformance block counts inventory entries matched by the pinned snapshot, so the same word meant two different things in one file. Rename the inventory counters to inventoryMethodsImplemented/Unimplemented and spell out both meanings in the note. --- contracts/reports/conformance.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/contracts/reports/conformance.json b/contracts/reports/conformance.json index e15f6ba..2c2a3c2 100644 --- a/contracts/reports/conformance.json +++ b/contracts/reports/conformance.json @@ -2,9 +2,9 @@ "repository": "sdk-python", "language": "Python", "status": "implemented", - "matched": 383, - "unmatched": 0, - "note": "Hand-written resource methods match contracts/sdk-endpoints.json. Payloads use the validated JSON boundary where operation schemas are absent.", + "inventoryMethodsImplemented": 383, + "inventoryMethodsUnimplemented": 0, + "note": "inventoryMethodsImplemented counts contracts/sdk-endpoints.json entries backed by hand-written resource methods; inventoryMethodsUnimplemented counts entries with no method. Snapshot conformance is reported separately under openapiConformance.", "openapiConformance": { "api": { "snapshotOperations": 383, From 857cd8d1611deac7b5b22faf4994d2fe24142d09 Mon Sep 17 00:00:00 2001 From: Gabriel Fonseca Date: Mon, 12 Oct 2026 13:56:08 +0100 Subject: [PATCH 24/29] refactor(core): dispatch error construction through a lookup table Replace the six-branch if-chain in error_for_status() with a _STATUS_ERRORS table plus a _classify() helper that maps a status code and error code to an error subtype and fix hint. The if-chain pushed the function past SonarQube Cloud's cognitive complexity limit of 15, which failed the pull request quality gate. The table form keeps the hint text and the error codes it applies to side by side, so adding a status no longer grows a nesting-sensitive branch chain. Behavior is unchanged: the same error subtypes, hints, and codes are produced for every status/code pair, including the generic fallback hint when the API returns no error code. --- frontal_sdk/core/errors.py | 116 +++++++++++++++---------------------- 1 file changed, 46 insertions(+), 70 deletions(-) diff --git a/frontal_sdk/core/errors.py b/frontal_sdk/core/errors.py index cfc4d2c..7edeb44 100644 --- a/frontal_sdk/core/errors.py +++ b/frontal_sdk/core/errors.py @@ -124,6 +124,32 @@ class TimeoutError(NetworkError): """The request exceeded the configured timeout.""" +_API_KEY_HINT = "Check FRONTAL_API_KEY — it must be a valid key starting with frt_." +_NOT_FOUND_HINT = "Verify the resource id belongs to this workspace/environment." +_FIELDS_HINT = "Fix the offending properties listed in `fields`." +_BACKOFF_HINT = "Back off and retry after `retryAfter`, or lower concurrency." +_UNAVAILABLE_HINT = "Service is temporarily unavailable; retry with backoff." +_GENERIC_HINT = "Check the API response for details." + +_AUTH_CODES = frozenset({"UNAUTHORIZED", "INVALID_API_KEY"}) +_NOT_FOUND_CODES = frozenset({"NOT_FOUND"}) +_CONFLICT_CODES = frozenset({"VALIDATION_ERROR"}) +_INPUT_CODES = frozenset({"BAD_REQUEST", "VALIDATION_ERROR"}) +_RATE_LIMIT_CODES = frozenset({"RATE_LIMITED"}) +_UNAVAILABLE_CODES = frozenset({"SERVICE_UNAVAILABLE", "INTERNAL_ERROR"}) + +# HTTP status -> (error subtype, fix hint, error codes the hint applies to). +_STATUS_ERRORS: dict[int, tuple[type[FrontalError], str, frozenset[str]]] = { + 400: (ValidationError, _FIELDS_HINT, _INPUT_CODES), + 401: (AuthenticationError, _API_KEY_HINT, _AUTH_CODES), + 403: (AuthenticationError, _API_KEY_HINT, _AUTH_CODES), + 404: (NotFoundError, _NOT_FOUND_HINT, _NOT_FOUND_CODES), + 409: (ConflictError, _FIELDS_HINT, _CONFLICT_CODES), + 422: (ValidationError, _FIELDS_HINT, _INPUT_CODES), + 429: (RateLimitError, _BACKOFF_HINT, _RATE_LIMIT_CODES), +} + + def error_for_status( status_code: int, message: str, @@ -137,13 +163,12 @@ def error_for_status( ) -> FrontalError: """Create the public error subtype corresponding to an HTTP status. - The returned error carries the API's error ``code`` when available, a - human-readable ``fix`` remediation hint, and optional ``rateLimit`` - metadata from response headers. The ``safe_to_retry`` flag indicates - whether retrying the same request may succeed (rate limits, transient - 5xx, network). UIs and agents can branch on this without a status table. + The returned error carries the API's error ``code`` when available and a + human-readable ``fix`` remediation hint. The ``safe_to_retry`` flag + indicates whether retrying the same request may succeed (rate limits, + transient 5xx, network). UIs and agents can branch on this without a + status table. """ - # Build context without ``code`` so we can pass it as a named arg. arguments: _ErrorContext = { "request_id": request_id, "status_code": status_code, @@ -151,75 +176,26 @@ def error_for_status( "fields": fields, "safe_to_retry": safe_to_retry, } - effective_code = code - - if status_code in {401, 403}: - fix = ( - "Check FRONTAL_API_KEY — it must be a valid key starting with frt_." - if effective_code in {"UNAUTHORIZED", "INVALID_API_KEY"} - else None - ) - return AuthenticationError(message, code=effective_code, fix=fix, **arguments) - if status_code == 404: - return NotFoundError( - message, - code=effective_code, - fix=( - "Verify the resource id belongs to this workspace/environment." - if effective_code == "NOT_FOUND" - else None - ), - **arguments, - ) - if status_code == 409: - return ConflictError( - message, - code=effective_code, - fix=( - "Fix the offending properties listed in `fields`." - if effective_code == "VALIDATION_ERROR" - else None - ), - **arguments, - ) - if status_code == 429: + error_type, fix = _classify(status_code, code) + if error_type is RateLimitError: return RateLimitError( - message, - code=effective_code, - retry_after=retry_after, - fix=( - "Back off and retry after `retryAfter`, or lower concurrency." - if effective_code == "RATE_LIMITED" - else None - ), - **arguments, - ) - if status_code in {400, 422}: - return ValidationError( - message, - code=effective_code, - fix=( - "Fix the offending properties listed in `fields`." - if effective_code in {"BAD_REQUEST", "VALIDATION_ERROR"} - else None - ), - **arguments, + message, code=code, retry_after=retry_after, fix=fix, **arguments ) + return error_type(message, code=code, fix=fix, **arguments) + + +def _classify( + status_code: int, code: str | None +) -> tuple[type[FrontalError], str | None]: + """Map an HTTP status and API error code to an error type and fix hint.""" + if status_code in _STATUS_ERRORS: + error_type, hint, hint_codes = _STATUS_ERRORS[status_code] + return error_type, hint if code in hint_codes else None if status_code >= 500: - return ServerError( - message, - code=effective_code, - fix=( - "Service is temporarily unavailable; retry with backoff." - if effective_code in {"SERVICE_UNAVAILABLE", "INTERNAL_ERROR"} - else None - ), - **arguments, - ) + return ServerError, _UNAVAILABLE_HINT if code in _UNAVAILABLE_CODES else None # For unmapped status codes return a generic FrontalError carrying the # API's error code when known. - fix = "Check the API response for details." if effective_code is None else None - return FrontalError(message, code=effective_code, fix=fix, **arguments) + return FrontalError, _GENERIC_HINT if code is None else None __all__ = [ From 75267c3ce542849c9b22f66af06d207f87e2a5d0 Mon Sep 17 00:00:00 2001 From: Gabriel Fonseca Date: Mon, 12 Oct 2026 13:58:33 +0100 Subject: [PATCH 25/29] refactor(scripts): split contract checker main into focused functions main() in scripts/check_contracts.py reached a cognitive complexity of 55 against a limit of 15, failing the SonarQube Cloud quality gate on every pull request. Split it into single-responsibility helpers: validate_inputs, load_specs, process_domain, domain_expected_operations, report_resource_mismatch, operation_in_spec, compute_coverage, collect_obsolete_package_errors, print_summary, and print_floor_drift. main() now only wires them together. The script's stdout and exit codes are byte-identical to before, so the contract gate and the committed reports are unaffected. Two hardening changes: the coverage floor is now shape-checked before use instead of failing with an AttributeError, and the resolved HTTP method is threaded through explicitly so the snapshot lookup cannot silently switch back to the alias form. --- scripts/check_contracts.py | 224 ++++++++++++++++++++++++++----------- 1 file changed, 158 insertions(+), 66 deletions(-) diff --git a/scripts/check_contracts.py b/scripts/check_contracts.py index 1e34fdf..c253fc3 100644 --- a/scripts/check_contracts.py +++ b/scripts/check_contracts.py @@ -204,6 +204,34 @@ def matches_operation( def main() -> None: + """Validate the committed contracts and report snapshot conformance.""" + inventory, coverage_floor = validate_inputs() + specs = load_specs() + + sdk_by_surface: dict[str, list[tuple[str, tuple[str, ...]]]] = defaultdict(list) + errors: list[str] = [] + missing_from_spec: list[tuple[str, str, str]] = [] + expected_count = 0 + + for domain, entries in inventory.items(): + domain_count, domain_drift = process_domain( + domain, entries, specs, sdk_by_surface, errors + ) + expected_count += domain_count + missing_from_spec.extend(domain_drift) + + collect_obsolete_package_errors(errors) + coverage, uncovered_spec = compute_coverage(specs, sdk_by_surface) + + if errors: + raise SystemExit("\n".join(errors)) + + print_summary(expected_count, coverage, missing_from_spec, uncovered_spec) + print_floor_drift(coverage, coverage_floor) + + +def validate_inputs() -> tuple[dict[str, Any], dict[str, Any]]: + """Load the shared contract inputs and check the coverage floor shape.""" for path in FILES: load_json(path) @@ -211,81 +239,39 @@ def main() -> None: coverage_floor = load_json(COVERAGE_FLOOR_PATH) if not isinstance(inventory, dict): raise SystemExit("endpoint inventory must be a JSON object") + if not isinstance(coverage_floor, dict): + raise SystemExit("coverage floor must be a JSON object") for surface in ("api", "ai"): value = coverage_floor.get(surface) if not isinstance(value, int) or value < 0: raise SystemExit( f"coverage floor must contain a non-negative {surface} count" ) + return inventory, coverage_floor + - specs = { +def load_specs() -> dict[str, set[tuple[str, str]]]: + """Parse the published OpenAPI snapshots for both surfaces.""" + return { "api": parse_openapi(API_OPENAPI_PATH), "ai": parse_openapi(AI_OPENAPI_PATH), } - sdk_by_surface: dict[str, list[tuple[str, tuple[str, ...]]]] = defaultdict(list) - errors: list[str] = [] - missing_from_spec: list[tuple[str, str, str]] = [] - expected_count = 0 - for domain, entries in inventory.items(): - if not isinstance(domain, str) or not isinstance(entries, list): - errors.append(f"invalid endpoint inventory entry: {domain!r}") - continue - resource_path = ROOT / "frontal_sdk/resources" / f"{domain}.py" - if not entries: - if resource_path.exists(): - errors.append(f"unexpected resource module for empty domain: {domain}") - continue - if not resource_path.is_file(): - errors.append(f"missing resource module: {resource_path.relative_to(ROOT)}") - continue - - implemented, resource_errors = resource_operations(resource_path) - errors.extend(resource_errors) - expected: set[tuple[str, str]] = set() - surface = "ai" if domain == "ai" else "api" - for entry in entries: - if not isinstance(entry, dict): - errors.append(f"invalid operation in {domain}: {entry!r}") - continue - method, path = entry.get("method"), entry.get("path") - if not isinstance(method, str) or not isinstance(path, str): - errors.append(f"invalid method/path in {domain}: {entry!r}") - continue - expected.add((method, path)) - resolved_method = resolve_method(method) - if not surface_allows(surface, path): - errors.append( - f"forbidden {surface} surface operation: {domain} {method} {path}" - ) - sdk_candidates = candidate_paths(surface, path) - sdk_by_surface[surface].append((resolved_method, sdk_candidates)) - if not any( - resolved_method == spec_method - and any( - path_matches(spec_path, candidate) for candidate in sdk_candidates - ) - for spec_method, spec_path in specs[surface] - ): - missing_from_spec.append((domain, method, path)) - - if len(expected) != len(entries): - errors.append(f"duplicate operations in {domain} endpoint inventory") - missing = expected - implemented - unexpected = implemented - expected - if missing or unexpected: - errors.append( - f"resource mismatch for {domain}: missing={sorted(missing)}, " - f"unexpected={sorted(unexpected)}" - ) - expected_count += len(expected) +def collect_obsolete_package_errors(errors: list[str]) -> None: + """Flag packages that were removed from the SDK but still exist.""" for obsolete_package in ("services", "utils"): if (ROOT / "frontal_sdk" / obsolete_package).exists(): errors.append( f"obsolete frontal_sdk/{obsolete_package} package still exists" ) + +def compute_coverage( + specs: dict[str, set[tuple[str, str]]], + sdk_by_surface: dict[str, list[tuple[str, tuple[str, ...]]]], +) -> tuple[dict[str, tuple[int, int]], dict[str, list[tuple[str, str]]]]: + """Match each snapshot operation against the endpoint inventory.""" coverage: dict[str, tuple[int, int]] = {} uncovered_spec: dict[str, list[tuple[str, str]]] = {} for surface, operations in specs.items(): @@ -296,10 +282,123 @@ def main() -> None: ] coverage[surface] = (len(operations) - len(uncovered), len(operations)) uncovered_spec[surface] = uncovered + return coverage, uncovered_spec + + +def process_domain( + domain: Any, + entries: Any, + specs: dict[str, set[tuple[str, str]]], + sdk_by_surface: dict[str, list[tuple[str, tuple[str, ...]]]], + errors: list[str], +) -> tuple[int, list[tuple[str, str, str]]]: + """Validate one inventory domain, returning its size and any snapshot drift.""" + if not isinstance(domain, str) or not isinstance(entries, list): + errors.append(f"invalid endpoint inventory entry: {domain!r}") + return 0, [] + + resource_path = ROOT / "frontal_sdk/resources" / f"{domain}.py" + if not entries: + if resource_path.exists(): + errors.append(f"unexpected resource module for empty domain: {domain}") + return 0, [] + + if not resource_path.is_file(): + errors.append(f"missing resource module: {resource_path.relative_to(ROOT)}") + return 0, [] + + implemented, resource_errors = resource_operations(resource_path) + errors.extend(resource_errors) + surface = "ai" if domain == "ai" else "api" + expected, missing_from_spec = domain_expected_operations( + domain, entries, surface, specs, sdk_by_surface, errors + ) + + if len(expected) != len(entries): + errors.append(f"duplicate operations in {domain} endpoint inventory") + report_resource_mismatch(domain, expected, implemented, errors) + return len(expected), missing_from_spec + + +def domain_expected_operations( + domain: str, + entries: list[Any], + surface: str, + specs: dict[str, set[tuple[str, str]]], + sdk_by_surface: dict[str, list[tuple[str, tuple[str, ...]]]], + errors: list[str], +) -> tuple[set[tuple[str, str]], list[tuple[str, str, str]]]: + """Expand inventory entries into the operations a resource must expose.""" + expected: set[tuple[str, str]] = set() + missing_from_spec: list[tuple[str, str, str]] = [] + for entry in entries: + if not isinstance(entry, dict): + errors.append(f"invalid operation in {domain}: {entry!r}") + continue + method, path = entry.get("method"), entry.get("path") + if not isinstance(method, str) or not isinstance(path, str): + errors.append(f"invalid method/path in {domain}: {entry!r}") + continue + expected.add((method, path)) + if not surface_allows(surface, path): + errors.append( + f"forbidden {surface} surface operation: {domain} {method} {path}" + ) + resolved_method = resolve_method(method) + sdk_candidates = candidate_paths(surface, path) + sdk_by_surface[surface].append((resolved_method, sdk_candidates)) + if not operation_in_spec(resolved_method, sdk_candidates, specs[surface]): + missing_from_spec.append((domain, method, path)) + return expected, missing_from_spec + + +def report_resource_mismatch( + domain: str, + expected: set[tuple[str, str]], + implemented: set[tuple[str, str]], + errors: list[str], +) -> None: + """Compare the operations a resource implements against the inventory.""" + missing = expected - implemented + unexpected = implemented - expected + if missing or unexpected: + errors.append( + f"resource mismatch for {domain}: missing={sorted(missing)}, " + f"unexpected={sorted(unexpected)}" + ) + + +def operation_in_spec( + method: str, candidates: tuple[str, ...], spec: set[tuple[str, str]] +) -> bool: + """Report whether a resource operation appears in an OpenAPI snapshot.""" + return any( + spec_method == method + and any(path_matches(spec_path, candidate) for candidate in candidates) + for spec_method, spec_path in spec + ) + + +def print_floor_drift( + coverage: dict[str, tuple[int, int]], coverage_floor: dict[str, Any] +) -> None: + """Report parts of a surface whose matched baseline fell below its floor.""" + for surface, actual in coverage.items(): + floor = coverage_floor[surface] + if actual[0] < floor: + print( + f"OpenAPI coverage floor drift for {surface}: " + f"{actual[0]} matched, previous floor {floor}" + ) - if errors: - raise SystemExit("\n".join(errors)) +def print_summary( + expected_count: int, + coverage: dict[str, tuple[int, int]], + missing_from_spec: list[tuple[str, str, str]], + uncovered_spec: dict[str, list[tuple[str, str]]], +) -> None: + """Print the contract summary followed by the informational drift reports.""" api_covered, api_total = coverage["api"] ai_covered, ai_total = coverage["ai"] print( @@ -328,13 +427,6 @@ def main() -> None: ) for group in sorted(uncovered_counts): print(f" {group}: {uncovered_counts[group]} operations") - for surface, actual in coverage.items(): - floor = coverage_floor[surface] - if actual[0] < floor: - print( - f"OpenAPI coverage floor drift for {surface}: " - f"{actual[0]} matched, previous floor {floor}" - ) if __name__ == "__main__": From bf7c4abdbdff349a816911105c69ed099371ae36 Mon Sep 17 00:00:00 2001 From: Gabriel Fonseca Date: Mon, 12 Oct 2026 14:40:23 +0100 Subject: [PATCH 26/29] fix(core): export circuit breaker error and state from the facade CircuitBreakerOpenError and CircuitState are public in frontal_sdk.core.circuit_breaker but were absent from frontal_sdk.core, so callers who import from the package facade could not catch the open-circuit error. --- frontal_sdk/core/__init__.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/frontal_sdk/core/__init__.py b/frontal_sdk/core/__init__.py index 3f4a544..5b2028a 100644 --- a/frontal_sdk/core/__init__.py +++ b/frontal_sdk/core/__init__.py @@ -2,7 +2,12 @@ from __future__ import annotations -from frontal_sdk.core.circuit_breaker import CircuitBreaker, CircuitBreakerConfig +from frontal_sdk.core.circuit_breaker import ( + CircuitBreaker, + CircuitBreakerConfig, + CircuitBreakerOpenError, + CircuitState, +) from frontal_sdk.core.config import ClientConfig from frontal_sdk.core.constants import DEFAULT_BASE_URL, SDK_VERSION from frontal_sdk.core.errors import ( @@ -31,6 +36,8 @@ "calculate_delay", "CircuitBreaker", "CircuitBreakerConfig", + "CircuitBreakerOpenError", + "CircuitState", "ClientConfig", "ConflictError", "DEFAULT_BASE_URL", From c96789a5737d217dd7f4c802ef493435db02e314 Mon Sep 17 00:00:00 2001 From: Gabriel Fonseca Date: Mon, 12 Oct 2026 14:41:15 +0100 Subject: [PATCH 27/29] fix(core): invoke circuit breaker callbacks outside the lock Transition callbacks (on_open, on_close, on_half_open) fired while self._lock was held. Because the lock is a non-reentrant threading.Lock, any callback that read breaker.state or breaker.failures, or called force_close, deadlocked the thread. Record the transition under the lock and announce it after release. The mutating helpers (_record, _record_failure, _reset) now assume the caller already holds the lock, matching how execute(), execute_async(), and force_close() use them. Also narrow the failure path from BaseException to Exception so asyncio.CancelledError, KeyboardInterrupt, and SystemExit no longer count as service failures. Only errors raised by the operation itself open the circuit; is_failure still classifies non-raising responses. Adds tests/test_circuit_breaker.py covering the callbacks, the cancellation rule, and the state machine. --- frontal_sdk/core/circuit_breaker.py | 95 +++++++----- tests/test_circuit_breaker.py | 219 ++++++++++++++++++++++++++++ 2 files changed, 281 insertions(+), 33 deletions(-) create mode 100644 tests/test_circuit_breaker.py diff --git a/frontal_sdk/core/circuit_breaker.py b/frontal_sdk/core/circuit_breaker.py index 9f6a2bc..826cb91 100644 --- a/frontal_sdk/core/circuit_breaker.py +++ b/frontal_sdk/core/circuit_breaker.py @@ -61,6 +61,11 @@ class CircuitBreaker: While the circuit is ``open`` (or a half-open probe is in flight) calls raise :class:`CircuitBreakerOpenError` instead of reaching the API. A successful probe closes the circuit again. + + Transition callbacks are invoked without the breaker lock held, + so a callback may read :attr:`state` or :attr:`failures`. It must + not call back into the breaker (for example :meth:`force_close`), + which recurses instead of blocking. """ def __init__(self, config: CircuitBreakerConfig | None = None) -> None: @@ -93,22 +98,20 @@ def execute( ``is_failure`` classifies a returned (non-raising) result as a failure, so HTTP error responses can count too. """ - probe = self._enter() + probe, announce = self._enter() + _notify(announce) try: try: result = fn() failed = is_failure is not None and is_failure(result) - except BaseException: + except Exception: with self._lock: - self._record_failure(probe) + announce = self._record_failure(probe) + _notify(announce) raise with self._lock: - if failed: - self._record_failure(probe) - elif probe: - self._reset() - else: - self._failures = 0 + announce = self._record(failed, probe) + _notify(announce) return result finally: with self._lock: @@ -121,22 +124,20 @@ async def execute_async( is_failure: Callable[[ResultT], bool] | None = None, ) -> ResultT: """Run an awaitable ``fn`` through the breaker.""" - probe = self._enter() + probe, announce = self._enter() + _notify(announce) try: try: result = await fn() failed = is_failure is not None and is_failure(result) - except BaseException: + except Exception: with self._lock: - self._record_failure(probe) + announce = self._record_failure(probe) + _notify(announce) raise with self._lock: - if failed: - self._record_failure(probe) - elif probe: - self._reset() - else: - self._failures = 0 + announce = self._record(failed, probe) + _notify(announce) return result finally: with self._lock: @@ -153,40 +154,68 @@ def force_open(self) -> None: def force_close(self) -> None: """Manually reset the circuit to the ``closed`` state.""" with self._lock: - self._reset() + announce = self._reset() + _notify(announce) + + def _enter(self) -> tuple[bool, list[Callable[[], None] | None]]: + """Apply state transitions before a call. - def _enter(self) -> bool: - """Apply state transitions before a call; return whether - this call is a half-open probe.""" + Return whether this call is a half-open probe, along with the + transition callbacks to run once the lock has been released. + """ with self._lock: + announce: list[Callable[[], None] | None] = [] if self._state == "open": elapsed = time.monotonic() - self._last_failure if elapsed <= self._config.reset_timeout: raise CircuitBreakerOpenError(self._config.reset_timeout - elapsed) self._state = "half_open" - if self._config.on_half_open is not None: - self._config.on_half_open() - probe = self._state == "half_open" - if probe: + announce.append(self._config.on_half_open) + if self._state == "half_open": if self._probe_in_flight: raise CircuitBreakerOpenError(self._config.reset_timeout) self._probe_in_flight = True - return probe + return True, announce + return False, announce - def _record_failure(self, probe: bool) -> None: + def _record(self, failed: bool, probe: bool) -> list[Callable[[], None] | None]: + """Record a call that returned, returning the transition to announce. + + The caller holds ``self._lock``; taking it here would self-deadlock. + """ + if failed: + return self._record_failure(probe) + if probe: + return self._reset() + self._failures = 0 + return [] + + def _record_failure(self, probe: bool) -> list[Callable[[], None] | None]: + """Count a failure; the caller holds ``self._lock``.""" self._failures += 1 self._last_failure = time.monotonic() if probe or self._failures >= self._config.failure_threshold: if self._state != "open": self._state = "open" - if self._config.on_open is not None: - self._config.on_open() + return [self._config.on_open] + return [] - def _reset(self) -> None: + def _reset(self) -> list[Callable[[], None] | None]: + """Return the circuit to closed; the caller holds ``self._lock``.""" self._failures = 0 self._state = "closed" - if self._config.on_close is not None: - self._config.on_close() + return [self._config.on_close] + + +def _notify(callbacks: list[Callable[[], None] | None]) -> None: + """Invoke transition callbacks while no breaker lock is held. + + Callbacks run outside the lock so a callback may read ``state`` or + ``failures``, or call ``force_close``, without deadlocking the breaker. + """ + for callback in callbacks: + if callback is not None: + callback() __all__ = [ diff --git a/tests/test_circuit_breaker.py b/tests/test_circuit_breaker.py new file mode 100644 index 0000000..5fc86a0 --- /dev/null +++ b/tests/test_circuit_breaker.py @@ -0,0 +1,219 @@ +"""Circuit breaker tests covering state transitions and lock safety.""" + +from __future__ import annotations + +import asyncio +import threading +import time +from typing import Any, Callable + +import pytest +from frontal_sdk import core +from frontal_sdk.core.circuit_breaker import ( + CircuitBreaker, + CircuitBreakerConfig, + CircuitBreakerOpenError, + CircuitState, +) + + +def _boom() -> Any: + raise RuntimeError("upstream is down") + + +def _ok() -> str: + return "ok" + + +def _server_error() -> int: + return 500 + + +def _fail_once(breaker: CircuitBreaker) -> None: + """Drive one failure through the breaker on the calling thread.""" + with pytest.raises(RuntimeError): + breaker.execute(_boom) + + +def _succeed_once(breaker: CircuitBreaker) -> str: + return breaker.execute(_ok) + + +def _run_with_timeout(fn: Callable[[], Any], timeout: float = 5.0) -> Any: + """Run ``fn`` on a worker thread, failing instead of hanging on deadlock.""" + outcome: list[Any] = [] + failure: list[BaseException] = [] + + def runner() -> None: + try: + outcome.append(fn()) + except BaseException as exc: # noqa: BLE001 - re-raised below by the caller + failure.append(exc) + + thread = threading.Thread(target=runner, daemon=True) + thread.start() + thread.join(timeout) + if thread.is_alive(): + pytest.fail("circuit breaker deadlocked") + if failure: + raise failure[0] + return outcome[0] + + +def test_public_core_exports_expose_breaker_names() -> None: + assert core.CircuitBreaker is CircuitBreaker + assert core.CircuitBreakerOpenError is CircuitBreakerOpenError + assert "CircuitBreakerOpenError" in core.__all__ + assert "CircuitState" in core.__all__ + + +def test_open_callback_may_read_breaker_state() -> None: + observed: list[tuple[CircuitState, int]] = [] + + def on_open() -> None: + # Deadlocks if the callback fires while the breaker lock is held. + observed.append((breaker.state, breaker.failures)) + + breaker = CircuitBreaker(CircuitBreakerConfig(failure_threshold=1, on_open=on_open)) + + with pytest.raises(RuntimeError): + _run_with_timeout(lambda: breaker.execute(_boom)) + + assert observed == [("open", 1)] + + +def test_close_callback_may_read_breaker_state() -> None: + observed: list[tuple[CircuitState, int]] = [] + + def on_close() -> None: + # Deadlocks if the callback fires while the breaker lock is held. + observed.append((breaker.state, breaker.failures)) + + breaker = CircuitBreaker( + CircuitBreakerConfig(failure_threshold=1, reset_timeout=0.01, on_close=on_close) + ) + + _run_with_timeout(lambda: _fail_once(breaker)) + time.sleep(0.02) + assert _run_with_timeout(lambda: _succeed_once(breaker)) == "ok" + + assert observed == [("closed", 0)] + + +def test_half_open_callback_may_read_breaker_state() -> None: + observed: list[CircuitState] = [] + + def on_half_open() -> None: + observed.append(breaker.state) + + breaker = CircuitBreaker( + CircuitBreakerConfig( + failure_threshold=1, reset_timeout=0.01, on_half_open=on_half_open + ) + ) + + _run_with_timeout(lambda: _fail_once(breaker)) + time.sleep(0.02) + assert _run_with_timeout(lambda: _succeed_once(breaker)) == "ok" + + assert observed == ["half_open"] + + +@pytest.mark.parametrize( + "cancelled", + [asyncio.CancelledError, KeyboardInterrupt, SystemExit], +) +def test_non_error_base_exceptions_do_not_open_the_circuit( + cancelled: type[BaseException], +) -> None: + def _cancelled() -> Any: + raise cancelled("stopped") + + breaker = CircuitBreaker(CircuitBreakerConfig(failure_threshold=1)) + + with pytest.raises(cancelled): + breaker.execute(_cancelled) + + assert breaker.state == "closed" + assert breaker.failures == 0 + + +def test_failures_open_the_circuit_and_short_circuit_calls() -> None: + breaker = CircuitBreaker(CircuitBreakerConfig(failure_threshold=2)) + + for _ in range(2): + with pytest.raises(RuntimeError): + breaker.execute(_boom) + + assert breaker.state == "open" + assert breaker.failures == 2 + with pytest.raises(CircuitBreakerOpenError): + breaker.execute(_ok) + + +def test_successful_probe_closes_the_circuit() -> None: + breaker = CircuitBreaker( + CircuitBreakerConfig(failure_threshold=1, reset_timeout=0.01) + ) + + with pytest.raises(RuntimeError): + breaker.execute(_boom) + assert breaker.state == "open" + + time.sleep(0.02) + assert breaker.execute(_ok) == "ok" + assert breaker.state == "closed" + assert breaker.failures == 0 + + +def test_result_predicate_counts_non_raising_failures() -> None: + breaker = CircuitBreaker(CircuitBreakerConfig(failure_threshold=1)) + + def is_failure(status: int) -> bool: + return status >= 500 + + assert breaker.execute(_server_error, is_failure=is_failure) == 500 + + assert breaker.state == "open" + with pytest.raises(CircuitBreakerOpenError): + breaker.execute(_ok) + + +def test_force_close_reopens_and_accepts_a_new_probe() -> None: + breaker = CircuitBreaker( + CircuitBreakerConfig(failure_threshold=1, reset_timeout=10) + ) + _fail_once(breaker) + _run_with_timeout(breaker.force_close) + assert breaker.state == "closed" + assert breaker.failures == 0 + assert _run_with_timeout(lambda: _succeed_once(breaker)) == "ok" + + +async def test_async_cancel_does_not_open_the_circuit() -> None: + async def _cancelled() -> Any: + raise asyncio.CancelledError("stopped") + + breaker = CircuitBreaker(CircuitBreakerConfig(failure_threshold=1)) + + with pytest.raises(asyncio.CancelledError): + await breaker.execute_async(_cancelled) + + assert breaker.state == "closed" + assert breaker.failures == 0 + + +async def test_async_failures_open_the_circuit() -> None: + async def _boom_async() -> Any: + raise RuntimeError("upstream is down") + + async def _ok_async() -> str: + return "ok" + + breaker = CircuitBreaker(CircuitBreakerConfig(failure_threshold=1)) + with pytest.raises(RuntimeError): + await breaker.execute_async(_boom_async) + + assert breaker.state == "open" + with pytest.raises(CircuitBreakerOpenError): + await breaker.execute_async(_ok_async) From 6cc3b951d4b5462e7cbc6ea5d8c8b40dd5615740 Mon Sep 17 00:00:00 2001 From: Gabriel Fonseca Date: Mon, 12 Oct 2026 14:41:43 +0100 Subject: [PATCH 28/29] docs(resources): correct rerank, patch, and approval descriptions Three docstrings promised behavior the methods do not have: - SyncAI.rerank and AsyncAI.rerank claimed to reorder documents, but both return RerankResult scores and the caller applies them. - FunctionAccessor.update and WorkflowAccessor.update said "Replace" while sending a PATCH, so omitted fields are preserved rather than overwritten. - WorkflowApprovals.list described a pending-only list, but it sends no status filter when callers omit status. --- frontal_sdk/resources/ai.py | 6 ++++-- frontal_sdk/resources/functions.py | 3 ++- frontal_sdk/resources/workflows.py | 6 ++++-- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/frontal_sdk/resources/ai.py b/frontal_sdk/resources/ai.py index 869f94a..facedee 100644 --- a/frontal_sdk/resources/ai.py +++ b/frontal_sdk/resources/ai.py @@ -1011,7 +1011,8 @@ def moderate( return ModerationResult.model_validate(self.create_prediction(body=body)) def rerank(self, options: RerankOptions | Mapping[str, JSONValue]) -> RerankResult: - """Reorder candidate documents by relevance to the query.""" + """Score candidate documents against the query; apply the scores to + reorder the documents yourself.""" values = ( options if isinstance(options, RerankOptions) @@ -1330,7 +1331,8 @@ async def moderate( async def rerank( self, options: RerankOptions | Mapping[str, JSONValue] ) -> RerankResult: - """Reorder candidate documents by relevance to the query.""" + """Score candidate documents against the query; apply the scores to + reorder the documents yourself.""" values = ( options if isinstance(options, RerankOptions) diff --git a/frontal_sdk/resources/functions.py b/frontal_sdk/resources/functions.py index 8cfa915..d07589a 100644 --- a/frontal_sdk/resources/functions.py +++ b/frontal_sdk/resources/functions.py @@ -539,7 +539,8 @@ def get(self) -> JSONResultT: def update( self, definition: FunctionDefinition | Mapping[str, JSONValue] ) -> JSONResultT: - """Replace this function's definition with the given fields.""" + """Update this function's definition, sending a PATCH so omitted + fields are left unchanged.""" return self._functions.update(self.id, definition) def delete(self) -> JSONResultT: diff --git a/frontal_sdk/resources/workflows.py b/frontal_sdk/resources/workflows.py index 419d308..e308012 100644 --- a/frontal_sdk/resources/workflows.py +++ b/frontal_sdk/resources/workflows.py @@ -747,7 +747,8 @@ def get(self) -> JSONResultT: return self._workflows.get_workflow(self.id) def update(self, definition: Mapping[str, JSONValue]) -> JSONResultT: - """Replace this workflow's definition with the given fields.""" + """Update this workflow's definition, sending a PATCH so omitted + fields are left unchanged.""" return self._workflows.update_workflow(self.id, body=dict(definition)) def delete(self) -> JSONResultT: @@ -868,7 +869,8 @@ def list( limit: int | None = None, cursor: str | None = None, ) -> JSONResultT: - """List pending approvals with optional status pagination.""" + """List approvals, optionally filtered by status, with optional + status pagination.""" return self._workflows.list_approvals( query=_page_query(status=status, limit=limit, cursor=cursor) ) From 6db1ede2dca6c55f721d2a6d4a847c87d41c04c2 Mon Sep 17 00:00:00 2001 From: Gabriel Fonseca Date: Mon, 12 Oct 2026 14:42:07 +0100 Subject: [PATCH 29/29] fix(scripts): skip deleted markdown in the docs manifest git ls-files lists tracked paths even when their deletion has not been staged, so a maintainer who deletes a tracked .md file before regenerating gets a link to a missing file in docs/mcp.json and mcp.json. Check that each path still refers to a file. --- scripts/generate_docs_manifest.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/generate_docs_manifest.py b/scripts/generate_docs_manifest.py index d113a2f..0d139ea 100644 --- a/scripts/generate_docs_manifest.py +++ b/scripts/generate_docs_manifest.py @@ -19,7 +19,11 @@ def tracked_markdown() -> list[Path]: text=True, check=True, ).stdout - return [ROOT / line for line in output.splitlines() if line] + # ``git ls-files`` still lists tracked paths whose deletion has not been + # staged, so check the working tree to avoid linking to a missing file. + return [ + ROOT / line for line in output.splitlines() if line and (ROOT / line).is_file() + ] paths = sorted(p for p in tracked_markdown() if p.name != "PLAN.md")