From e68999ceeee4e28aab878295fb913698d56aba2f Mon Sep 17 00:00:00 2001 From: Jean-Adrien DUCASTAING Date: Tue, 15 Sep 2026 18:02:51 +0200 Subject: [PATCH] feat(exceptions): dedicated error for service maintenance --- AGENTS.md | 15 ++++++-- lighton/exceptions.py | 54 +++++++++++++++++++++++++++++ tests/test_client.py | 80 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 147 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ba9429d..eec4106 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,7 +93,7 @@ a field whose full domain is known, `workspace_type`/`document_upload_method` st `{path: [Attribute]}` **map**, where a live node carries its own flat list. It lives in `content_type.py` next to `Attribute`/`Facet` (the local precedent for taxonomy-adjacent data models) rather than under `types/`. -- Deferred: streaming, add the params when needed. **`POST /api/v3/preview`** (any supported document to PDF, sync, 20 MB cap) is deliberately **not** a verb: it's internal tooling, not SDK surface. `_request("POST", "/api/v3/preview", files=..., raw=True)` reaches it in one line if that ever changes, which is part of what the `raw` flag buys. Also unwrapped from the current OpenAPI schema: `POST /api/v3/content-types/scope` (`FacetScopeRequest`/`FacetScopeResponse`, LLM scope inference), `WorkspaceTaxonomy` on the workspace list response, and the `ServiceMaintenance503` body (a 503 maps to the generic error today, no dedicated exception). +- Deferred: streaming, add the params when needed. **`POST /api/v3/preview`** (any supported document to PDF, sync, 20 MB cap) is deliberately **not** a verb: it's internal tooling, not SDK surface. `_request("POST", "/api/v3/preview", files=..., raw=True)` reaches it in one line if that ever changes, which is part of what the `raw` flag buys. Also unwrapped from the current OpenAPI schema: `POST /api/v3/content-types/scope` (`FacetScopeRequest`/`FacetScopeResponse`, LLM scope inference) and `scoped_api_keys` on the workspace responses. - **Config object.** Non-essential knobs (`base_url`, `timeout`, `retries`, `transport`) live in `LightOnConfiguration` (pydantic, `arbitrary_types_allowed`). `api_key` stays a direct `LightOn()` arg; falls back to `LIGHTON_API_KEY` env. - **Retries / rate limiting.** Two layers: `httpx.HTTPTransport(retries=)` handles *connection* errors (exp. backoff); `_request` itself handles **HTTP 429**, retries up to @@ -119,9 +119,20 @@ key), `PermissionDeniedError` (403, authenticated but not allowed, e.g. an endpo needing CompanyAdmin; a **sibling** of `AuthenticationError`, not a subclass, so 401 and 403 are caught separately), `NotFoundError` (404), `RateLimitError` (429, also carries `retry_after`, the `Retry-After` header in seconds via `_retry_after()`, or None; HTTP-date form not -parsed), `ServerError` (5xx). +parsed), `ServerError` (5xx), and `MaintenanceError`. `exceptions.from_response()` maps status → class. `MalformedResponseError` (sibling of `LightOnAPIError`, not a subclass), a 2xx body that isn't JSON. +- **`MaintenanceError(ServerError)`** is the one mapping keyed on the **body**, not the + status: a 503 carrying `error: "service_maintenance"` (the schema's + `ServiceMaintenance503`). A planned window and a crash share status 503, and only one + of them is worth coming back for, so callers need to tell them apart. It **subclasses** + `ServerError` so existing `except ServerError` keeps working, and carries `mode` + (`full_shutdown`/`warning_banner`, both blocking), `reason`, `started_at` and + `endpoint_categories` (empty = every endpoint). `started_at` parses via `_timestamp()` + and is `None` if absent or unparsable, which loses nothing because the untouched + payload stays on `.body`. A 503 **without** the marker stays a plain `ServerError` + (pinned by a test). Still not retried: 5xx never is, and a maintenance window outlasts + any cooldown worth sleeping through. ## Resource management: active-record diff --git a/lighton/exceptions.py b/lighton/exceptions.py index 9b5665d..78da737 100644 --- a/lighton/exceptions.py +++ b/lighton/exceptions.py @@ -2,6 +2,7 @@ from __future__ import annotations +from datetime import datetime from typing import Any import httpx @@ -67,6 +68,39 @@ class ServerError(LightOnAPIError): """5xx, the API failed to handle the request.""" +class MaintenanceError(ServerError): + """503 during a planned maintenance window, not a crash. + + A `ServerError` subclass, so existing `except ServerError` handlers keep + working; catch this specifically to tell "come back later" apart from "this + broke", since only one of the two is worth retrying. + + `mode` is `full_shutdown` or `warning_banner` (both block the request), + `reason` is operator-supplied text, `started_at` is when the window opened, + and `endpoint_categories` names the affected categories, empty meaning every + endpoint. The untouched payload is always on `.body`. + """ + + def __init__( + self, + message: str, + *, + status_code: int, + body: Any = None, + mode: str | None = None, + reason: str | None = None, + started_at: datetime | None = None, + endpoint_categories: list[str] | None = None, + ) -> None: + super().__init__(message, status_code=status_code, body=body) + self.mode = mode + self.reason = reason + self.started_at = started_at + self.endpoint_categories = endpoint_categories or [] + + +_MAINTENANCE = "service_maintenance" + _STATUS_MAP = { 401: AuthenticationError, 403: PermissionDeniedError, @@ -92,6 +126,16 @@ def from_response(response: httpx.Response) -> LightOnAPIError: body=body, retry_after=_retry_after(response), ) + if isinstance(body, dict) and body.get("error") == _MAINTENANCE: + return MaintenanceError( + message, + status_code=response.status_code, + body=body, + mode=body.get("mode"), + reason=body.get("reason"), + started_at=_timestamp(body.get("started_at")), + endpoint_categories=body.get("endpoint_category_names"), + ) return cls(message, status_code=response.status_code, body=body) @@ -105,6 +149,16 @@ def _retry_after(response: httpx.Response) -> float | None: return None +def _timestamp(raw: Any) -> datetime | None: + """Parse an ISO-8601 timestamp, or None. The raw value stays on `.body`.""" + if not isinstance(raw, str): + return None + try: + return datetime.fromisoformat(raw) + except ValueError: + return None + + def _safe_body(response: httpx.Response) -> Any: try: return response.json() diff --git a/tests/test_client.py b/tests/test_client.py index 6b49ec7..4c6b2e2 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -213,3 +213,83 @@ def test_raw_empty_body_is_empty_bytes_not_none(): # The JSON path turns an empty 2xx into None; the bytes path must not. client = make_client(lambda req: httpx.Response(200, content=b"")) assert client._request("GET", "/api/v3/files/7/download", raw=True) == b"" + + +# --- maintenance windows ---------------------------------------------------- +# A 503 from the maintenance middleware is worth retrying later; a 503 from a +# crash is not. They arrive on the same status code, so the body tells them apart. + +_MAINTENANCE_BODY = { + "detail": "System is under maintenance.", + "error": "service_maintenance", + "mode": "full_shutdown", + "reason": "database migration", + "started_at": "2026-09-15T08:30:00Z", + "endpoint_category_names": ["search", "ingestion"], +} + + +def test_maintenance_503_raises_a_dedicated_error_with_its_fields(): + client = make_client(lambda req: httpx.Response(503, json=_MAINTENANCE_BODY)) + + with pytest.raises(exc.MaintenanceError) as excinfo: + client.ask("q") + + e = excinfo.value + assert e.mode == "full_shutdown" + assert e.reason == "database migration" + assert e.started_at is not None and e.started_at.year == 2026 + assert e.endpoint_categories == ["search", "ingestion"] + assert e.status_code == 503 + assert e.body == _MAINTENANCE_BODY # the untouched payload is still there + assert "System is under maintenance." in str(e) + + +def test_maintenance_error_is_still_a_server_error(): + # Subclassing keeps existing `except ServerError` handlers working. + client = make_client(lambda req: httpx.Response(503, json=_MAINTENANCE_BODY)) + with pytest.raises(exc.ServerError): + client.ask("q") + assert issubclass(exc.MaintenanceError, exc.ServerError) + + +def test_a_plain_503_stays_a_server_error(): + client = make_client(lambda req: httpx.Response(503, json={"detail": "boom"})) + with pytest.raises(exc.ServerError) as excinfo: + client.ask("q") + assert type(excinfo.value) is exc.ServerError, ( + "a crash must not read as maintenance" + ) + + +def test_maintenance_survives_a_missing_or_unparsable_timestamp(): + # reason/started_at are optional; a bad timestamp must not break the raise. + body = {"detail": "down", "error": "service_maintenance", "mode": "warning_banner"} + client = make_client(lambda req: httpx.Response(503, json=body)) + with pytest.raises(exc.MaintenanceError) as excinfo: + client.ask("q") + assert excinfo.value.started_at is None + assert excinfo.value.reason is None + assert excinfo.value.endpoint_categories == [] # empty means every endpoint + + client = make_client( + lambda req: httpx.Response(503, json={**body, "started_at": "not a date"}) + ) + with pytest.raises(exc.MaintenanceError) as excinfo: + client.ask("q") + assert excinfo.value.started_at is None + assert excinfo.value.body["started_at"] == "not a date" # raw value preserved + + +def test_maintenance_is_not_retried_like_a_429(monkeypatch): + # 5xx is deliberately not retried: the window outlasts any cooldown we'd wait. + monkeypatch.setattr(_client_mod.time, "sleep", lambda _s: None) + calls = {"n": 0} + + def handler(req: httpx.Request) -> httpx.Response: + calls["n"] += 1 + return httpx.Response(503, json=_MAINTENANCE_BODY) + + with pytest.raises(exc.MaintenanceError): + make_client(handler, rate_limit_retries=3).ask("q") + assert calls["n"] == 1