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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
54 changes: 54 additions & 0 deletions lighton/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

from datetime import datetime
from typing import Any

import httpx
Expand Down Expand Up @@ -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,
Expand All @@ -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)


Expand All @@ -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()
Expand Down
80 changes: 80 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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