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
60 changes: 60 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,10 +123,70 @@ try {
}
```

## Idempotent requests

`register`, `batchRegister` and `batchRegisterAsync` accept an idempotency key,
sent as the `Idempotency-Key` header. If the same key arrives again within 24
hours with the same request, the API does not enrol a second time: it replays
the first response (same status and body) with the header
`Idempotent-Replayed: true`. So when a connection drops and you cannot tell
whether the enrolment happened, send it again with the same key.

- Same key, different request: `422 IDEMPOTENCY_KEY_MISMATCH`.
- Same key while the first request is still running: `409 IDEMPOTENCY_KEY_IN_USE`.
- `429` and `5xx` responses are not remembered, so retrying with the same key
runs the request again.
- Other `4xx` responses are remembered: after fixing the request (e.g. a new
photo after `NO_FACE_DETECTED`), send it with a **new** key.

```typescript
import { generateIdempotencyKey } from 'livexface'

const key = generateIdempotencyKey() // a random UUID v4; store it with the job
await client.faces.register('collection-id', {
externalId: 'user_123',
image: fs.readFileSync('./photo.jpg'),
idempotencyKey: key,
})

await client.faces.batchRegister('collection-id', items, { idempotencyKey: generateIdempotencyKey() })
```

## Production retries

Retries are off by default. `maxRetries` turns them on (attempts after the
first). `429` and `503` are retried after the `Retry-After` delay (capped by
`maxRetryDelay`), or after an exponential backoff with jitter. Network errors
and other `5xx` are retried only for GET, PATCH and DELETE and for requests
with an idempotency key; a plain POST such as `identify` is not. Other `4xx`
responses are never retried. Enrolment and batch calls get a generated key when
you pass none, and every attempt of one call sends the same key.

```typescript
import { LiveXFace, LiveXFaceApiError, generateIdempotencyKey } from 'livexface'

const client = new LiveXFace({ apiKey: process.env.LIVEXFACE_API_KEY!, maxRetries: 3 })

try {
const face = await client.faces.register('collection-id', {
externalId: 'user_123',
image: fs.readFileSync('./photo.jpg'),
idempotencyKey: generateIdempotencyKey(),
})
} catch (err) {
if (err instanceof LiveXFaceApiError) {
// After the last retry: e.g. 429 with retryAfter = 12 (seconds)
console.error(err.statusCode, err.code, `retry after ${err.retryAfter}s`, err.requestId)
}
}
```

## Configuration

| Option | Type | Default | Description |
| --------- | -------- | ------------------------------------ | ------------------------------- |
| `apiKey` | `string` | **required** | Your API key (`lxf_live_xxx`) |
| `baseUrl` | `string` | `http://localhost:8080/api/v1` | Base URL of the LiveXFace server|
| `timeout` | `number` | `30000` | Request timeout in milliseconds |
| `maxRetries` | `number` | `0` (off) | Retries after the first attempt; see [Production retries](#production-retries) |
| `maxRetryDelay` | `number` | `60000` | Longest wait before one retry, in milliseconds |
113 changes: 110 additions & 3 deletions src/client.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as crypto from "crypto";
import * as fs from "fs";
import * as path from "path";
import { LiveXFaceApiError, LiveXFaceNetworkError } from "./error";
Expand All @@ -22,10 +23,14 @@ import type {
AttributesInput,
AttributesResult,
BatchJob,
IdempotencyOptions,
} from "./types";

const DEFAULT_BASE_URL = "http://localhost:8080/api/v1";
const DEFAULT_TIMEOUT = 30_000;
const DEFAULT_MAX_RETRY_DELAY = 60_000;
// Repeating these is harmless, so a network error or 5xx may be retried.
const SAFE_METHODS = ["GET", "PATCH", "DELETE"];

type ImageSource = Blob | Buffer | ArrayBuffer | string; // string = file path

Expand All @@ -46,6 +51,21 @@ async function toBlob(src: ImageSource, filename = "image.jpg"): Promise<Blob> {
return new Blob([src], { type: "image/jpeg" });
}

/**
* Generate a random idempotency key (a UUID v4) for the enrolment and batch
* methods. Uses Web Crypto where present (browsers, Node 19+), else Node's
* crypto module.
*/
export function generateIdempotencyKey(): string {
return globalThis.crypto?.randomUUID?.() ?? crypto.randomUUID();
}

/** Whole seconds from a `Retry-After` header; an HTTP-date is ignored. */
function parseRetryAfter(value: string | null): number | undefined {
const v = value?.trim();
return v && /^\d+$/.test(v) ? Number(v) : undefined;
}

/**
* LiveXFace — main client for the LiveXFace recognition API.
*
Expand All @@ -70,27 +90,93 @@ export class LiveXFace {
private readonly baseUrl: string;
private readonly apiKey: string;
private readonly timeout: number;
private readonly maxRetries: number;
private readonly maxRetryDelay: number;

/** @internal Replaced in tests so retries do not really wait. */
_sleep = (ms: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, ms));

public readonly faces: FacesResource;

constructor(config: LiveXFaceConfig) {
this.baseUrl = (config.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, "");
this.apiKey = config.apiKey;
this.timeout = config.timeout ?? DEFAULT_TIMEOUT;
this.maxRetries = config.maxRetries ?? 0;
this.maxRetryDelay = config.maxRetryDelay ?? DEFAULT_MAX_RETRY_DELAY;

this.faces = new FacesResource(this);
}

/**
* @internal The caller's key, or with retries on a fresh one, so every
* attempt of one enrolment call carries the same key.
*/
_idempotencyKey(key?: string): string | undefined {
return key ?? (this.maxRetries > 0 ? generateIdempotencyKey() : undefined);
}

/** @internal */
async _request<T>(
method: string,
endpoint: string,
options: { body?: BodyInit; json?: unknown; formData?: FormData } = {},
options: RequestOptions = {},
): Promise<T> {
const safe =
options.idempotencyKey !== undefined || SAFE_METHODS.includes(method);
for (let attempt = 0; ; attempt++) {
try {
return await this._attempt<T>(method, endpoint, options);
} catch (err) {
const delay =
attempt < this.maxRetries
? this._retryDelay(err, safe, attempt)
: undefined;
if (delay === undefined) throw err;
await this._sleep(delay);
}
}
}

/** Milliseconds to wait before retrying after `err`, or undefined to give up. */
private _retryDelay(
err: unknown,
safe: boolean,
attempt: number,
): number | undefined {
let retryAfter: number | undefined;
if (err instanceof LiveXFaceApiError) {
const status = err.statusCode;
if (status === 429 || status === 503) retryAfter = err.retryAfter;
else if (status < 500 || !safe) return undefined;
} else if (!(err instanceof LiveXFaceNetworkError) || !safe) {
return undefined;
}
// Full jitter: anywhere between 0 and 0.5 s * 2^attempt.
const ms =
retryAfter !== undefined
? retryAfter * 1000
: Math.random() * 500 * 2 ** attempt;
return Math.min(ms, this.maxRetryDelay);
}

/**
* One HTTP round trip. The body is rebuilt from `options` each time; a
* FormData or JSON string can be sent again, so retries replay it.
*/
private async _attempt<T>(
method: string,
endpoint: string,
options: RequestOptions,
): Promise<T> {
const url = `${this.baseUrl}${endpoint}`;
const headers: Record<string, string> = {
"X-API-Key": this.apiKey,
};
if (options.idempotencyKey !== undefined) {
headers["Idempotency-Key"] = options.idempotencyKey;
}

let body: BodyInit | undefined;
if (options.json !== undefined) {
Expand Down Expand Up @@ -131,6 +217,7 @@ export class LiveXFace {
return undefined as T;
}

const retryAfter = parseRetryAfter(response.headers.get("Retry-After"));
let parsed: APIResponse<T>;
try {
parsed = (await response.json()) as APIResponse<T>;
Expand All @@ -143,6 +230,9 @@ export class LiveXFace {
? "Failed to parse response body"
: `Request failed with HTTP ${response.status}`,
response.status,
undefined,
undefined,
retryAfter,
);
}

Expand All @@ -153,13 +243,21 @@ export class LiveXFace {
response.status,
parsed.requestId,
parsed.error?.details,
retryAfter,
);
}

return parsed.data as T;
}
}

type RequestOptions = {
body?: BodyInit;
json?: unknown;
formData?: FormData;
idempotencyKey?: string;
};

/**
* Serialize a batch item into its `entries` JSON object. `livenessToken` is
* omitted when unset rather than sent as null.
Expand Down Expand Up @@ -197,6 +295,7 @@ export class FacesResource {
`/collections/${collectionId}/faces`,
{
formData: form,
idempotencyKey: this.client._idempotencyKey(input.idempotencyKey),
},
);
}
Expand Down Expand Up @@ -350,6 +449,7 @@ export class FacesResource {
async batchRegister(
collectionId: string,
items: BatchRegisterItem[],
options: IdempotencyOptions = {},
): Promise<BatchResponse> {
const form = new FormData();
const entries = items.map(toBatchEntry);
Expand All @@ -364,7 +464,10 @@ export class FacesResource {
return this.client._request<BatchResponse>(
"POST",
`/collections/${collectionId}/faces/batch`,
{ formData: form },
{
formData: form,
idempotencyKey: this.client._idempotencyKey(options.idempotencyKey),
},
);
}

Expand Down Expand Up @@ -407,6 +510,7 @@ export class FacesResource {
async batchRegisterAsync(
collectionId: string,
items: BatchRegisterItem[],
options: IdempotencyOptions = {},
): Promise<BatchJob> {
const form = new FormData();
const entries = items.map(toBatchEntry);
Expand All @@ -421,7 +525,10 @@ export class FacesResource {
return this.client._request<BatchJob>(
"POST",
`/collections/${collectionId}/faces/batch-async`,
{ formData: form },
{
formData: form,
idempotencyKey: this.client._idempotencyKey(options.idempotencyKey),
},
);
}

Expand Down
4 changes: 4 additions & 0 deletions src/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,24 @@ export class LiveXFaceApiError extends Error {
public readonly requestId?: string
/** Machine-readable context when the API sends it, e.g. `faceCount` and `faces` for `MULTIPLE_FACES`. */
public readonly details?: Record<string, unknown>
/** Seconds from the response's `Retry-After` header (sent with 429 and 503), when it held a whole number. */
public readonly retryAfter?: number

constructor(
code: string,
message: string,
statusCode: number,
requestId?: string,
details?: Record<string, unknown>,
retryAfter?: number,
) {
super(message)
this.name = 'LiveXFaceApiError'
this.code = code
this.statusCode = statusCode
this.requestId = requestId
this.details = details
this.retryAfter = retryAfter
Object.setPrototypeOf(this, LiveXFaceApiError.prototype)
}
}
Expand Down
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
export { LiveXFace, FacesResource } from './client'
export { LiveXFace, FacesResource, generateIdempotencyKey } from './client'
export { LiveXFaceApiError, LiveXFaceNetworkError } from './error'
export type {
LiveXFaceConfig,
APIResponse,
APIError,
Face,
RegisterFaceInput,
IdempotencyOptions,
ListFacesInput,
ListFacesResponse,
VerifyInput,
Expand Down
21 changes: 21 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,18 @@ export interface RegisterFaceInput {
* collection requires liveness at enrolment.
*/
livenessToken?: string
/**
* Sent as the `Idempotency-Key` header: repeating the call with the same
* key replays the first result instead of enrolling again. See
* {@link generateIdempotencyKey}.
*/
idempotencyKey?: string
}

/** Options for the batch enrolment methods. */
export interface IdempotencyOptions {
/** Sent as the `Idempotency-Key` header; see {@link RegisterFaceInput.idempotencyKey}. */
idempotencyKey?: string
}

export interface ListFacesInput {
Expand Down Expand Up @@ -283,4 +295,13 @@ export interface LiveXFaceConfig {
baseUrl?: string
/** Request timeout in milliseconds. Defaults to 30000 */
timeout?: number
/**
* Retries after the first attempt. Defaults to 0 (off). 429 and 503 are
* retried after `Retry-After`; network errors and other 5xx only for
* GET/PATCH/DELETE and requests with an idempotency key. Enrolment and
* batch calls get a generated idempotency key when you give none.
*/
maxRetries?: number
/** Upper bound on one retry delay, in milliseconds. Defaults to 60000 */
maxRetryDelay?: number
}
Loading
Loading