diff --git a/README.md b/README.md index 8d2c703..50ead28 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,64 @@ 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 | @@ -130,3 +188,5 @@ try { | `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 | diff --git a/src/client.ts b/src/client.ts index c3f7926..165f80f 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,3 +1,4 @@ +import * as crypto from "crypto"; import * as fs from "fs"; import * as path from "path"; import { LiveXFaceApiError, LiveXFaceNetworkError } from "./error"; @@ -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 @@ -46,6 +51,21 @@ async function toBlob(src: ImageSource, filename = "image.jpg"): Promise { 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. * @@ -70,6 +90,12 @@ 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 => + new Promise((resolve) => setTimeout(resolve, ms)); public readonly faces: FacesResource; @@ -77,20 +103,80 @@ export class LiveXFace { 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( method: string, endpoint: string, - options: { body?: BodyInit; json?: unknown; formData?: FormData } = {}, + options: RequestOptions = {}, + ): Promise { + const safe = + options.idempotencyKey !== undefined || SAFE_METHODS.includes(method); + for (let attempt = 0; ; attempt++) { + try { + return await this._attempt(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( + method: string, + endpoint: string, + options: RequestOptions, ): Promise { const url = `${this.baseUrl}${endpoint}`; const headers: Record = { "X-API-Key": this.apiKey, }; + if (options.idempotencyKey !== undefined) { + headers["Idempotency-Key"] = options.idempotencyKey; + } let body: BodyInit | undefined; if (options.json !== undefined) { @@ -131,6 +217,7 @@ export class LiveXFace { return undefined as T; } + const retryAfter = parseRetryAfter(response.headers.get("Retry-After")); let parsed: APIResponse; try { parsed = (await response.json()) as APIResponse; @@ -143,6 +230,9 @@ export class LiveXFace { ? "Failed to parse response body" : `Request failed with HTTP ${response.status}`, response.status, + undefined, + undefined, + retryAfter, ); } @@ -153,6 +243,7 @@ export class LiveXFace { response.status, parsed.requestId, parsed.error?.details, + retryAfter, ); } @@ -160,6 +251,13 @@ export class LiveXFace { } } +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. @@ -197,6 +295,7 @@ export class FacesResource { `/collections/${collectionId}/faces`, { formData: form, + idempotencyKey: this.client._idempotencyKey(input.idempotencyKey), }, ); } @@ -350,6 +449,7 @@ export class FacesResource { async batchRegister( collectionId: string, items: BatchRegisterItem[], + options: IdempotencyOptions = {}, ): Promise { const form = new FormData(); const entries = items.map(toBatchEntry); @@ -364,7 +464,10 @@ export class FacesResource { return this.client._request( "POST", `/collections/${collectionId}/faces/batch`, - { formData: form }, + { + formData: form, + idempotencyKey: this.client._idempotencyKey(options.idempotencyKey), + }, ); } @@ -407,6 +510,7 @@ export class FacesResource { async batchRegisterAsync( collectionId: string, items: BatchRegisterItem[], + options: IdempotencyOptions = {}, ): Promise { const form = new FormData(); const entries = items.map(toBatchEntry); @@ -421,7 +525,10 @@ export class FacesResource { return this.client._request( "POST", `/collections/${collectionId}/faces/batch-async`, - { formData: form }, + { + formData: form, + idempotencyKey: this.client._idempotencyKey(options.idempotencyKey), + }, ); } diff --git a/src/error.ts b/src/error.ts index 11ba8f9..ff37649 100644 --- a/src/error.ts +++ b/src/error.ts @@ -4,6 +4,8 @@ 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 + /** 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, @@ -11,6 +13,7 @@ export class LiveXFaceApiError extends Error { statusCode: number, requestId?: string, details?: Record, + retryAfter?: number, ) { super(message) this.name = 'LiveXFaceApiError' @@ -18,6 +21,7 @@ export class LiveXFaceApiError extends Error { this.statusCode = statusCode this.requestId = requestId this.details = details + this.retryAfter = retryAfter Object.setPrototypeOf(this, LiveXFaceApiError.prototype) } } diff --git a/src/index.ts b/src/index.ts index 0653bdc..f23ec44 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -export { LiveXFace, FacesResource } from './client' +export { LiveXFace, FacesResource, generateIdempotencyKey } from './client' export { LiveXFaceApiError, LiveXFaceNetworkError } from './error' export type { LiveXFaceConfig, @@ -6,6 +6,7 @@ export type { APIError, Face, RegisterFaceInput, + IdempotencyOptions, ListFacesInput, ListFacesResponse, VerifyInput, diff --git a/src/types.ts b/src/types.ts index fe9d2be..400011f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 { @@ -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 } diff --git a/test/retry.test.js b/test/retry.test.js new file mode 100644 index 0000000..236caca --- /dev/null +++ b/test/retry.test.js @@ -0,0 +1,185 @@ +// Idempotency keys, Retry-After and the opt-in retry policy, against a local +// HTTP server so dropped connections are real transport errors. +const { test, before, after, beforeEach } = require('node:test') +const assert = require('node:assert/strict') +const http = require('node:http') +const { LiveXFace, LiveXFaceApiError, LiveXFaceNetworkError, generateIdempotencyKey } = require('../dist') + +// Each test queues one handler per expected request; `requests` records what arrived. +let handlers = [] +let requests = [] +let baseUrl + +const server = http.createServer((req, res) => { + req.resume() + req.on('end', () => { + requests.push({ method: req.method, url: req.url, headers: req.headers }) + const handle = handlers.shift() + if (!handle) { + res.writeHead(500).end('no handler queued') + return + } + handle(req, res) + }) +}) + +const reply = (status, body, headers = {}) => (req, res) => { + res.writeHead(status, { 'Content-Type': 'application/json', ...headers }) + res.end(JSON.stringify(body)) +} +const fail = (status, code, headers) => + reply(status, { success: false, requestId: 'req_1', error: { code, message: code.toLowerCase() } }, headers) +const drop = (req, res) => res.socket.destroy() + +before(async () => { + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + baseUrl = `http://127.0.0.1:${server.address().port}/api/v1` +}) +after(() => { + server.closeAllConnections() + server.close() +}) +beforeEach(() => { + handlers = [] + requests = [] +}) + +const face = { id: 'f1', collectionId: 'col_1', externalId: 'u1', metadata: {}, createdAt: 'x' } +const img = () => Buffer.from([0xff, 0xd8, 0xff]) +const enrol = (client, extra = {}) => client.faces.register('col_1', { externalId: 'u1', image: img(), ...extra }) + +// A client whose sleeps are recorded instead of waited. +function makeClient(config = {}) { + const client = new LiveXFace({ apiKey: 'lxf_test_x', baseUrl, ...config }) + client.slept = [] + client._sleep = async (ms) => { + client.slept.push(ms) + } + return client +} + +test('429 with retries off exposes status, code, requestId and retryAfter', async () => { + handlers = [fail(429, 'RATE_LIMIT_EXCEEDED', { 'Retry-After': '12' })] + const client = makeClient() + await assert.rejects(enrol(client), (err) => { + assert.ok(err instanceof LiveXFaceApiError) + assert.equal(err.statusCode, 429) + assert.equal(err.code, 'RATE_LIMIT_EXCEEDED') + assert.equal(err.requestId, 'req_1') + assert.equal(err.retryAfter, 12) + return true + }) + assert.equal(requests.length, 1) +}) + +test('retryAfter is undefined without a parseable Retry-After', async () => { + handlers = [fail(503, 'SERVICE_BUSY', { 'Retry-After': 'Wed, 21 Oct 2026 07:28:00 GMT' })] + await assert.rejects(enrol(makeClient()), (err) => err.statusCode === 503 && err.retryAfter === undefined) +}) + +test('503 SERVICE_BUSY is retried after Retry-After with the same generated key', async () => { + handlers = [fail(503, 'SERVICE_BUSY', { 'Retry-After': '5' }), reply(201, { success: true, data: face })] + const client = makeClient({ maxRetries: 2 }) + const res = await enrol(client) + assert.deepEqual(res, face) + assert.equal(requests.length, 2) + const key = requests[0].headers['idempotency-key'] + assert.match(key, /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/) + assert.equal(requests[1].headers['idempotency-key'], key) + assert.deepEqual(client.slept, [5000]) +}) + +test('Retry-After is capped by maxRetryDelay', async () => { + handlers = [fail(429, 'RATE_LIMIT_EXCEEDED', { 'Retry-After': '120' }), reply(201, { success: true, data: face })] + const client = makeClient({ maxRetries: 1, maxRetryDelay: 2000 }) + await enrol(client) + assert.deepEqual(client.slept, [2000]) +}) + +test('a dropped enrolment is retried with the same key and returns the replay', async () => { + handlers = [drop, reply(201, { success: true, data: face }, { 'Idempotent-Replayed': 'true' })] + const client = makeClient({ maxRetries: 3 }) + const res = await enrol(client) + assert.deepEqual(res, face) + assert.equal(requests.length, 2) + assert.ok(requests[0].headers['idempotency-key']) + assert.equal(requests[1].headers['idempotency-key'], requests[0].headers['idempotency-key']) + // No Retry-After on a network error: jittered backoff, 0..500 ms on the first retry. + assert.equal(client.slept.length, 1) + assert.ok(client.slept[0] >= 0 && client.slept[0] <= 500) +}) + +test('422 NO_FACE_DETECTED is never retried', async () => { + handlers = [fail(422, 'NO_FACE_DETECTED')] + const client = makeClient({ maxRetries: 3 }) + await assert.rejects(enrol(client), (err) => err instanceof LiveXFaceApiError && err.code === 'NO_FACE_DETECTED') + assert.equal(requests.length, 1) +}) + +test('retries stop after maxRetries and surface the last error', async () => { + handlers = [1, 2, 3].map(() => fail(503, 'SERVICE_BUSY')) + const client = makeClient({ maxRetries: 2 }) + await assert.rejects(enrol(client), (err) => err.statusCode === 503) + assert.equal(requests.length, 3) + assert.equal(client.slept.length, 2) + assert.ok(client.slept[1] <= 1000) +}) + +test('a caller key is sent on every attempt; no key and retries off sends none', async () => { + handlers = [fail(500, 'INTERNAL_ERROR'), reply(201, { success: true, data: face })] + await enrol(makeClient({ maxRetries: 1 }), { idempotencyKey: 'my-key-1' }) + assert.deepEqual(requests.map((r) => r.headers['idempotency-key']), ['my-key-1', 'my-key-1']) + + requests = [] + handlers = [reply(201, { success: true, data: face })] + await enrol(makeClient()) + assert.equal('idempotency-key' in requests[0].headers, false) +}) + +test('batch methods send a caller key', async () => { + const client = makeClient() + handlers = [ + reply(200, { success: true, data: { succeeded: 1, failed: 0, results: [] } }), + reply(202, { success: true, data: { id: 'job_1', status: 'queued' } }), + ] + const items = [{ externalId: 'u1', image: img() }] + await client.faces.batchRegister('col_1', items, { idempotencyKey: 'batch-1' }) + await client.faces.batchRegisterAsync('col_1', items, { idempotencyKey: 'batch-2' }) + assert.deepEqual(requests.map((r) => r.headers['idempotency-key']), ['batch-1', 'batch-2']) +}) + +test('generateIdempotencyKey returns distinct UUID v4 strings', () => { + const a = generateIdempotencyKey() + const b = generateIdempotencyKey() + const v4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ + assert.match(a, v4) + assert.match(b, v4) + assert.notEqual(a, b) +}) + +test('a keyless POST is not retried on a network error or 500, but is on 503', async () => { + const client = makeClient({ maxRetries: 3 }) + const identify = () => client.faces.identify('col_1', { image: img() }) + + handlers = [drop] + await assert.rejects(identify(), LiveXFaceNetworkError) + assert.equal(requests.length, 1) + + requests = [] + handlers = [fail(500, 'INTERNAL_ERROR')] + await assert.rejects(identify(), (err) => err.statusCode === 500) + assert.equal(requests.length, 1) + + requests = [] + handlers = [fail(503, 'SERVICE_BUSY'), reply(200, { success: true, data: { matches: [] } })] + await identify() + assert.equal(requests.length, 2) + assert.equal('idempotency-key' in requests[0].headers, false) +}) + +test('a GET is retried on a network error', async () => { + handlers = [drop, reply(200, { success: true, data: face })] + const res = await makeClient({ maxRetries: 1 }).faces.get('col_1', 'f1') + assert.deepEqual(res, face) + assert.equal(requests.length, 2) +})