From 5caef5437fff2c06bcf85a9caf29225b7a827fe4 Mon Sep 17 00:00:00 2001 From: Imron Reviady Date: Sat, 3 Oct 2026 18:38:40 +0700 Subject: [PATCH] Add idempotency keys, Retry-After and opt-in retries Enrolment, synchronous batch and asynchronous batch calls take an idempotency key, sent as the Idempotency-Key header, and a generator returns a random UUID v4 for it. The API replays the first outcome for an identical repeat, so a retry after a lost response cannot enrol twice. The typed API error now carries retryAfter, the seconds from a Retry-After header. Retries are off by default. When enabled with a maximum number of retries: 429 and 503 wait for Retry-After (capped, default 60 s) or an exponential backoff with jitter; network errors and other 5xx are retried only for GET, PATCH and DELETE and for keyed requests; other 4xx are never retried. Enrolment and batch calls generate one key per call when retries are on and none was given, and send it on every attempt. --- README.md | 43 +++++++++ livexface/__init__.py | 3 +- livexface/client.py | 99 +++++++++++++++++++-- livexface/exceptions.py | 4 + tests/test_retries.py | 191 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 333 insertions(+), 7 deletions(-) create mode 100644 tests/test_retries.py diff --git a/README.md b/README.md index a528fd5..53784c2 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,47 @@ except LiveXFaceNetworkError as e: print(f"Network error: {e}") ``` +`LiveXFaceApiError` carries `status_code`, `code`, the message (`str(e)`), +`request_id`, `details` (a dict, or `None`) and `retry_after`: the seconds from +the response's `Retry-After` header on a 429 or 503, or `None` when it had none. + +## Idempotent Requests + +`register`, `batch_register` and `batch_register_async` accept an +`idempotency_key`, sent as the `Idempotency-Key` header. The API remembers the +answer to a keyed request for 24 hours: sending the same request with the same +key again returns that stored answer, with the header `Idempotent-Replayed: true`, +instead of enrolling the faces a second time. So a call that timed out or lost +its connection can be repeated without creating duplicates. + +- The same key with a different request is answered 422 `IDEMPOTENCY_KEY_MISMATCH`. +- The same key while the first request is still running is answered 409 `IDEMPOTENCY_KEY_IN_USE`. +- 429 and 5xx answers are not remembered, so a retry with the same key runs the request again. +- Other 4xx answers are remembered: after fixing the request, send it with a new key. + +`new_idempotency_key()` returns a random key (a UUID v4). + +## Production Retries + +Retries are off by default. `max_retries` turns them on (the number of attempts +after the first): a 429 or 503 is retried after its `Retry-After`, capped at +`max_retry_delay`, or after an exponential backoff with jitter when it gives +none. A network error or another 5xx is retried only for reads, deletions and +calls that carry an idempotency key; other 4xx are never retried. Enrolment and +batch calls send one key on every attempt, generating it when you give none. + +```python +from livexface import LiveXFace, LiveXFaceApiError, new_idempotency_key + +client = LiveXFace(api_key="lxf_live_xxxx", max_retries=3) + +key = new_idempotency_key() # store it with your record to retry safely later +try: + face = client.faces.register("collection-uuid", open("alice.jpg", "rb"), "user_123", idempotency_key=key) +except LiveXFaceApiError as e: + print(f"[{e.code}] {e.status_code}: {e} (request {e.request_id}, retry after {e.retry_after}s)") +``` + ## Image Input Types The SDK accepts images as: @@ -133,3 +174,5 @@ The SDK accepts images as: | `api_key` | **required** | Your API key (`lxf_live_xxx`) | | `base_url` | `http://localhost:8080/api/v1` | Base URL of the LiveXFace server | | `timeout` | `30` | Request timeout in seconds | +| `max_retries` | `0` | Retries after the first attempt; 0 turns retries off | +| `max_retry_delay` | `60` | Longest wait between attempts, in seconds | diff --git a/livexface/__init__.py b/livexface/__init__.py index f86816c..c6462ac 100644 --- a/livexface/__init__.py +++ b/livexface/__init__.py @@ -1,6 +1,6 @@ """LiveXFace Python SDK — Face Recognition as a Service.""" -from .client import LiveXFace +from .client import LiveXFace, new_idempotency_key from .exceptions import LiveXFaceApiError, LiveXFaceNetworkError from .types import ( Face, @@ -21,6 +21,7 @@ __version__ = "0.1.0" __all__ = [ "LiveXFace", + "new_idempotency_key", "LiveXFaceApiError", "LiveXFaceNetworkError", "Face", diff --git a/livexface/client.py b/livexface/client.py index 2677878..f454984 100644 --- a/livexface/client.py +++ b/livexface/client.py @@ -4,8 +4,11 @@ import builtins import json +import random +import time +import uuid from pathlib import Path -from typing import Any, IO, Sequence, Union +from typing import Any, Callable, IO, Sequence, Union import requests from requests import Response @@ -25,9 +28,25 @@ DEFAULT_BASE_URL = "http://localhost:8080/api/v1" DEFAULT_TIMEOUT = 30 +DEFAULT_MAX_RETRY_DELAY = 60.0 ImageInput = Union[bytes, str, Path, IO[bytes]] +# Repeating these is harmless, so a network error or a 5xx may be retried. +_SAFE_METHODS = frozenset({"GET", "PATCH", "DELETE"}) + + +def new_idempotency_key() -> str: + """Return a random key (UUID v4) for the ``idempotency_key`` argument.""" + return str(uuid.uuid4()) + + +def _retry_after(resp: Response) -> int | None: + value = resp.headers.get("Retry-After") + if isinstance(value, str) and value.strip().isdecimal(): + return int(value) + return None + def _to_bytes_tuple(src: ImageInput, filename: str = "image.jpg") -> tuple[str, bytes, str]: """Convert an image source to a (filename, bytes, content_type) tuple for requests.""" @@ -70,16 +89,57 @@ def __init__( api_key: str, base_url: str = DEFAULT_BASE_URL, timeout: int = DEFAULT_TIMEOUT, + max_retries: int = 0, + max_retry_delay: float = DEFAULT_MAX_RETRY_DELAY, + sleep: Callable[[float], None] = time.sleep, ) -> None: + """ + :param max_retries: Retries after the first attempt; 0 (the default) + turns retries off. A 429 or 503 is retried after its + ``Retry-After`` (or an exponential backoff with jitter); a network + error or another 5xx only for GET, PATCH and DELETE calls and for + calls that carry an idempotency key. Other 4xx are never retried. + :param max_retry_delay: Upper bound, in seconds, of one wait between + attempts. + :param sleep: Called with the delay before each retry; tests replace it. + """ self.api_key = api_key self.base_url = base_url.rstrip("/") self.timeout = timeout + self.max_retries = max_retries + self.max_retry_delay = max_retry_delay + self._sleep = sleep self._session = requests.Session() self._session.headers.update({"X-API-Key": api_key}) self.faces = FacesResource(self) - def _request(self, method: str, endpoint: str, **kwargs: Any) -> Any: + def _request( + self, method: str, endpoint: str, idempotency_key: str | None = None, **kwargs: Any + ) -> Any: + if idempotency_key: + kwargs["headers"] = {"Idempotency-Key": idempotency_key} + # A keyed request is safe to repeat: the API replays the first answer. + safe = method in _SAFE_METHODS or bool(idempotency_key) + attempt = 0 + while True: + try: + return self._send(method, endpoint, **kwargs) + except LiveXFaceApiError as exc: + retryable = exc.status_code in (429, 503) or (exc.status_code >= 500 and safe) + if not retryable or attempt >= self.max_retries: + raise + wait: float | None = exc.retry_after + except LiveXFaceNetworkError: + if not safe or attempt >= self.max_retries: + raise + wait = None + if wait is None: + wait = random.uniform(0, 0.5 * 2**attempt) + self._sleep(min(wait, self.max_retry_delay)) + attempt += 1 + + def _send(self, method: str, endpoint: str, **kwargs: Any) -> Any: url = f"{self.base_url}{endpoint}" try: resp: Response = self._session.request( @@ -102,7 +162,10 @@ def _request(self, method: str, endpoint: str, **kwargs: Any) -> Any: # envelope; report the HTTP status rather than a parse failure. if not resp.ok: raise LiveXFaceApiError( - f"HTTP_{resp.status_code}", f"Request failed with HTTP {resp.status_code}", resp.status_code + f"HTTP_{resp.status_code}", + f"Request failed with HTTP {resp.status_code}", + resp.status_code, + retry_after=_retry_after(resp), ) from exc raise LiveXFaceApiError("PARSE_ERROR", "Failed to parse response body", resp.status_code) from exc @@ -114,6 +177,7 @@ def _request(self, method: str, endpoint: str, **kwargs: Any) -> Any: status_code=resp.status_code, request_id=parsed.get("requestId"), details=err.get("details"), + retry_after=_retry_after(resp), ) return parsed.get("data") @@ -125,6 +189,13 @@ class FacesResource: def __init__(self, client: LiveXFace) -> None: self._c = client + def _key(self, idempotency_key: str | None) -> str | None: + # With retries on, every attempt of one call must carry the same key, + # so a call without one gets its own. + if idempotency_key is None and self._c.max_retries > 0: + return new_idempotency_key() + return idempotency_key + def register( self, collection_id: str, @@ -132,12 +203,16 @@ def register( external_id: str, metadata: dict[str, Any] | None = None, liveness_token: str | None = None, + idempotency_key: str | None = None, ) -> Face: """Register a face in a collection. :param liveness_token: Token from a passed :meth:`active_liveness` check. Required when the collection requires liveness on enrolment; single-use, valid for 5 minutes, and bound to the collection. + :param idempotency_key: Sent as ``Idempotency-Key``; repeating the call + with the same key within 24 hours replays the first answer instead + of enrolling again. See :func:`new_idempotency_key`. """ fname, fbytes, ftype = _to_bytes_tuple(image) files = {"image": (fname, fbytes, ftype)} @@ -146,7 +221,13 @@ def register( data["metadata"] = json.dumps(metadata) if liveness_token: data["liveness_token"] = liveness_token - resp = self._c._request("POST", f"/collections/{collection_id}/faces", files=files, data=data) + resp = self._c._request( + "POST", + f"/collections/{collection_id}/faces", + idempotency_key=self._key(idempotency_key), + files=files, + data=data, + ) return Face.from_dict(resp) def list( @@ -293,13 +374,15 @@ def batch_register( self, collection_id: str, items: builtins.list[dict[str, Any]], + idempotency_key: str | None = None, ) -> BatchResponse: """ Batch register up to 20 faces in a single request. Each item must have ``image`` (ImageInput) and ``external_id`` (str). Optional ``metadata`` dict and ``liveness_token`` (str, from - :meth:`active_liveness`) are also supported. + :meth:`active_liveness`) are also supported. ``idempotency_key`` works + as in :meth:`register`. Example:: @@ -323,6 +406,7 @@ def batch_register( resp = self._c._request( "POST", f"/collections/{collection_id}/faces/batch", + idempotency_key=self._key(idempotency_key), files=files, data={"entries": json.dumps(entries)}, ) @@ -351,6 +435,7 @@ def batch_register_async( self, collection_id: str, items: builtins.list[dict[str, Any]], + idempotency_key: str | None = None, ) -> BatchJob: """ Submit up to 100 faces for asynchronous registration. Returns a job @@ -359,7 +444,8 @@ def batch_register_async( Each item must have ``image`` (ImageInput) and ``external_id`` (str). Optional ``metadata`` dict and ``liveness_token`` (str, from - :meth:`active_liveness`) are also supported. + :meth:`active_liveness`) are also supported. ``idempotency_key`` works + as in :meth:`register`. """ files: dict[str, Any] = {} entries: builtins.list[dict[str, Any]] = [] @@ -376,6 +462,7 @@ def batch_register_async( resp = self._c._request( "POST", f"/collections/{collection_id}/faces/batch-async", + idempotency_key=self._key(idempotency_key), files=files, data={"entries": json.dumps(entries)}, ) diff --git a/livexface/exceptions.py b/livexface/exceptions.py index 82ebbb7..fe87552 100644 --- a/livexface/exceptions.py +++ b/livexface/exceptions.py @@ -13,6 +13,7 @@ def __init__( status_code: int, request_id: str | None = None, details: dict[str, Any] | None = None, + retry_after: int | None = None, ) -> None: super().__init__(message) self.code = code @@ -21,6 +22,9 @@ def __init__( # Machine-readable context when the API sends it, e.g. faceCount and # faces for MULTIPLE_FACES. self.details = details + # Seconds from the response's Retry-After header (429, 503), or None + # when it had none. + self.retry_after = retry_after def __repr__(self) -> str: return f"LiveXFaceApiError(code={self.code!r}, status_code={self.status_code}, message={str(self)!r})" diff --git a/tests/test_retries.py b/tests/test_retries.py new file mode 100644 index 0000000..7f13d0b --- /dev/null +++ b/tests/test_retries.py @@ -0,0 +1,191 @@ +"""Idempotency keys, typed errors with Retry-After, and opt-in retries, +against a local HTTP server so headers and dropped connections are real.""" + +from __future__ import annotations + +import json +import threading +import uuid +from collections.abc import Iterator +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Any + +import pytest + +from livexface import LiveXFace, LiveXFaceApiError, LiveXFaceNetworkError, new_idempotency_key + +FACE = {"id": "f1", "collectionId": "c1", "externalId": "u1", "createdAt": "2026-09-28T00:00:00Z"} +DROP = "drop" # close the socket without answering + + +def _ok(status: int, data: Any, headers: dict[str, str] | None = None) -> tuple[int, dict[str, Any], dict[str, str]]: + return status, {"success": True, "data": data}, headers or {} + + +def _err( + status: int, code: str, headers: dict[str, str] | None = None +) -> tuple[int, dict[str, Any], dict[str, str]]: + body = {"success": False, "error": {"code": code, "message": code.lower()}, "requestId": "req-1"} + return status, body, headers or {} + + +class FakeAPI: + """Answers each request with the next scripted reply and records its headers.""" + + def __init__(self) -> None: + self.replies: list[Any] = [] + self.requests: list[dict[str, str]] = [] + api = self + + class Handler(BaseHTTPRequestHandler): + def _handle(self) -> None: + self.rfile.read(int(self.headers.get("Content-Length") or 0)) + api.requests.append(dict(self.headers)) + reply = api.replies.pop(0) + if reply == DROP: + self.close_connection = True + return + status, body, headers = reply + raw = json.dumps(body).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(raw))) + for k, v in headers.items(): + self.send_header(k, v) + self.end_headers() + self.wfile.write(raw) + + do_GET = do_POST = do_DELETE = _handle + + def log_message(self, *args: Any) -> None: + pass + + self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + self.base_url = f"http://127.0.0.1:{self.server.server_address[1]}/api/v1" + + def keys(self) -> list[str | None]: + return [h.get("Idempotency-Key") for h in self.requests] + + +@pytest.fixture +def api() -> Iterator[FakeAPI]: + fake = FakeAPI() + t = threading.Thread(target=fake.server.serve_forever, kwargs={"poll_interval": 0.01}, daemon=True) + t.start() + yield fake + fake.server.shutdown() + fake.server.server_close() + + +def _client(api: FakeAPI, sleeps: list[float], max_retries: int = 0) -> LiveXFace: + return LiveXFace(api_key="lxf_test", base_url=api.base_url, max_retries=max_retries, sleep=sleeps.append) + + +def test_rate_limited_error_exposes_retry_after_without_retrying(api: FakeAPI) -> None: + api.replies = [_err(429, "RATE_LIMIT_EXCEEDED", {"Retry-After": "12"})] + sleeps: list[float] = [] + + with pytest.raises(LiveXFaceApiError) as exc: + _client(api, sleeps).faces.identify("c1", b"img") + + assert exc.value.status_code == 429 + assert exc.value.code == "RATE_LIMIT_EXCEEDED" + assert exc.value.request_id == "req-1" + assert exc.value.retry_after == 12 + assert len(api.requests) == 1 + assert sleeps == [] + + +def test_busy_engine_is_retried_after_retry_after_with_same_key(api: FakeAPI) -> None: + api.replies = [_err(503, "SERVICE_BUSY", {"Retry-After": "5"}), _ok(201, FACE)] + sleeps: list[float] = [] + + face = _client(api, sleeps, max_retries=2).faces.register("c1", b"img", "u1") + + assert face.id == "f1" + keys = api.keys() + assert len(keys) == 2 + assert keys[0] and keys[0] == keys[1] + assert sleeps == [5] + + +def test_dropped_enrolment_is_retried_and_replayed(api: FakeAPI) -> None: + api.replies = [DROP, _ok(201, FACE, {"Idempotent-Replayed": "true"})] + sleeps: list[float] = [] + + face = _client(api, sleeps, max_retries=1).faces.register("c1", b"img", "u1") + + assert face.id == "f1" + keys = api.keys() + assert len(keys) == 2 + assert keys[0] and keys[0] == keys[1] + assert len(sleeps) == 1 and 0 <= sleeps[0] <= 0.5 + + +def test_validation_error_is_not_retried(api: FakeAPI) -> None: + api.replies = [_err(422, "NO_FACE_DETECTED")] + + with pytest.raises(LiveXFaceApiError) as exc: + _client(api, [], max_retries=3).faces.register("c1", b"img", "u1") + + assert exc.value.code == "NO_FACE_DETECTED" + assert len(api.requests) == 1 + + +@pytest.mark.parametrize( + ("method", "reply"), + [ + ("register", _ok(201, FACE)), + ("batch_register", _ok(200, {"succeeded": 1, "failed": 0, "results": []})), + ("batch_register_async", _ok(202, {"id": "j1", "status": "queued"})), + ], +) +def test_caller_key_is_sent_and_no_key_sends_no_header(api: FakeAPI, method: str, reply: Any) -> None: + api.replies = [reply, reply] + client = _client(api, []) + call = getattr(client.faces, method) + args: tuple[Any, ...] = ("c1", b"img", "u1") if method == "register" else ("c1", [{"external_id": "u1", "image": b"a"}]) + + call(*args, idempotency_key="key-123") + call(*args) + + assert api.keys() == ["key-123", None] + + +def test_new_idempotency_key_returns_distinct_uuid4() -> None: + a, b = new_idempotency_key(), new_idempotency_key() + assert a != b + assert uuid.UUID(a).version == 4 and uuid.UUID(b).version == 4 + + +@pytest.mark.parametrize("first", [DROP, _err(500, "INTERNAL_ERROR")]) +def test_unkeyed_post_is_not_retried_on_network_error_or_500(api: FakeAPI, first: Any) -> None: + api.replies = [first, _ok(200, {"matches": []})] + + with pytest.raises((LiveXFaceNetworkError, LiveXFaceApiError)): + _client(api, [], max_retries=3).faces.identify("c1", b"img") + + assert len(api.requests) == 1 + + +def test_unkeyed_post_is_retried_on_503(api: FakeAPI) -> None: + api.replies = [_err(503, "SERVICE_BUSY"), _ok(200, {"matches": []})] + sleeps: list[float] = [] + + _client(api, sleeps, max_retries=1).faces.identify("c1", b"img") + + assert len(api.requests) == 2 + assert len(sleeps) == 1 and 0 <= sleeps[0] <= 0.5 + assert api.keys() == [None, None] + + +def test_retries_stop_at_max_and_cap_the_delay(api: FakeAPI) -> None: + api.replies = [_err(503, "SERVICE_BUSY", {"Retry-After": "120"})] * 3 + sleeps: list[float] = [] + + with pytest.raises(LiveXFaceApiError) as exc: + _client(api, sleeps, max_retries=2).faces.get("c1", "f1") + + assert exc.value.status_code == 503 + assert len(api.requests) == 3 + assert sleeps == [60, 60]