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
43 changes: 43 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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 |
3 changes: 2 additions & 1 deletion livexface/__init__.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -21,6 +21,7 @@
__version__ = "0.1.0"
__all__ = [
"LiveXFace",
"new_idempotency_key",
"LiveXFaceApiError",
"LiveXFaceNetworkError",
"Face",
Expand Down
99 changes: 93 additions & 6 deletions livexface/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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."""
Expand Down Expand Up @@ -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(
Expand All @@ -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

Expand All @@ -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")
Expand All @@ -125,19 +189,30 @@ 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,
image: ImageInput,
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)}
Expand All @@ -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(
Expand Down Expand Up @@ -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::

Expand All @@ -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)},
)
Expand Down Expand Up @@ -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
Expand All @@ -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]] = []
Expand All @@ -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)},
)
Expand Down
4 changes: 4 additions & 0 deletions livexface/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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})"
Expand Down
Loading
Loading