From 710b5151eeb2ae1573019471f7f9a640fe7e5ae5 Mon Sep 17 00:00:00 2001 From: Pato Perpetua Date: Sat, 29 Aug 2026 20:58:22 +1000 Subject: [PATCH 1/2] feat(client): allow callers to supply a correlation ID on send() Let PostKitClient send x-correlation-id when provided, validate values client-side before the request, and surface the server correlation id on success and error paths. Closes #66. Co-authored-by: Cursor --- docs/guides/api-quickstart.md | 45 ++++--- packages/post-kit-client/README.md | 17 ++- packages/post-kit-client/src/client.spec.ts | 135 ++++++++++++++++++++ packages/post-kit-client/src/client.ts | 42 +++++- packages/post-kit-client/src/correlation.ts | 12 ++ packages/post-kit-client/src/errors.ts | 8 +- 6 files changed, 233 insertions(+), 26 deletions(-) create mode 100644 packages/post-kit-client/src/correlation.ts diff --git a/docs/guides/api-quickstart.md b/docs/guides/api-quickstart.md index 94b5ba1..0ade7de 100644 --- a/docs/guides/api-quickstart.md +++ b/docs/guides/api-quickstart.md @@ -121,21 +121,25 @@ const postKit = new PostKitClient({ apiKey: process.env.POSTKIT_API_KEY!, }); -const result = await postKit.send({ - template: 'auth.password-reset', - to: 'jane@example.com', - variables: { - name: 'Jane Doe', - resetUrl: 'https://app.example.com/reset?token=REPLACE_ME', +const result = await postKit.send( + { + template: 'auth.password-reset', + to: 'jane@example.com', + variables: { + name: 'Jane Doe', + resetUrl: 'https://app.example.com/reset?token=REPLACE_ME', + }, }, -}); + { correlationId: 'my-trace-01' }, +); -console.log(result.id, result.status); // " sent" +console.log(result.id, result.status); // "my-trace-01 sent" ``` -That produces byte-for-byte the request the `curl` above does (minus -`x-correlation-id`, which the client does not currently set — pass your trace -id via raw HTTP if you need to control it). +Pass `correlationId` on `send()` (or as a client constructor default) to send +`x-correlation-id`. Omit it and the API generates one; `result.id` on success +and `PostKitRequestError.correlationId` on failure always carry the id the +server used. ### Constructor options @@ -144,6 +148,7 @@ id via raw HTTP if you need to control it). | `endpoint` | — | Required. Base URL; a trailing slash is stripped. `send()` appends `/emails/send` | | `apiKey` | — | Required. Sent as `Authorization: Bearer` | | `timeout` | `30_000` | Milliseconds. `0` disables the client timeout — the request then runs until your own `AbortSignal` fires, or indefinitely | +| `correlationId` | — | Optional default `x-correlation-id` for every `send()` | | `fetch` | `globalThis.fetch` | Injectable `fetch`. Use it in tests so no socket is opened | Both `endpoint` and `apiKey` throw synchronously from the constructor if @@ -151,14 +156,21 @@ empty. ### `send(request, options?)` -`send(request: SendRequest, options?: { signal?: AbortSignal })` resolves to -`SendResponse` or throws `PostKitRequestError`. A per-call `signal` is combined -with the client timeout, so whichever fires first wins: +`send(request: SendRequest, options?: { signal?: AbortSignal; correlationId?: string })` +resolves to `SendResponse` or throws `PostKitRequestError`. A per-call `signal` +is combined with the client timeout, so whichever fires first wins: ```ts -await postKit.send(request, { signal: AbortSignal.timeout(5_000) }); +await postKit.send(request, { + signal: AbortSignal.timeout(5_000), + correlationId: 'my-trace-01', +}); ``` +Invalid `correlationId` values (empty, over 128 characters, or characters +other than alphanumeric / hyphen / underscore) throw `PostKitRequestError` with +code `INVALID_CORRELATION_ID` before any network call. + If **your** signal aborts, the original `AbortError` propagates unchanged; if the client's own timeout fires, you get a `PostKitRequestError` with code `TIMEOUT`. @@ -167,7 +179,7 @@ the client's own timeout fires, you get a `PostKitRequestError` with code | Property | Meaning | | --- | --- | -| `code` | A `PostKitErrorCode` from the API body, or `'TIMEOUT'` / `'NETWORK_ERROR'`, or `HTTP_` when the error body was not JSON | +| `code` | A `PostKitErrorCode` from the API body, or `'TIMEOUT'` / `'NETWORK_ERROR'` / `'INVALID_CORRELATION_ID'`, or `HTTP_` when the error body was not JSON | | `status` | HTTP status; `undefined` for timeouts and network failures | | `correlationId` | From the error body when present — log it | | `message` | The API's `error` text, or a generated fallback | @@ -187,6 +199,7 @@ the client's own timeout fires, you get a `PostKitRequestError` with code | `PROVIDER_FAILURE` | 502 / 500 | no | Provider rejected the message permanently, or an unexpected server error — investigate with the correlation id | | `TIMEOUT` | — | yes (idempotent) | Client-side timeout; delivery status is unknown — the send may still have happened | | `NETWORK_ERROR` | — | yes (idempotent) | Transport failure or a response-body read failure after headers arrived; delivery status is unknown — the API may have accepted the send before the body failed | +| `INVALID_CORRELATION_ID` | — | no | Fix the `correlationId` on `send()` or the client constructor — must be 8–128 alphanumeric / hyphen / underscore characters | Retry the "yes" rows with capped exponential backoff and jitter. Treat both `TIMEOUT` and `NETWORK_ERROR` as **delivery unknown** — make retries idempotent diff --git a/packages/post-kit-client/README.md b/packages/post-kit-client/README.md index f8f3b86..54d5663 100644 --- a/packages/post-kit-client/README.md +++ b/packages/post-kit-client/README.md @@ -32,6 +32,9 @@ await postKit.send({ to: 'hello@example.com', variables: { name, email, message }, }); + +// Optional: pass your own trace id (8–128 alphanumeric / hyphen / underscore) +await postKit.send(request, { correlationId: 'my-trace-01' }); ``` ### Options @@ -41,17 +44,29 @@ await postKit.send({ | `endpoint` | Base URL of the PostKit API (trailing slash stripped) | | `apiKey` | Bearer token for `Authorization` | | `timeout` | Request timeout ms (default `30_000`). Pass `0` to disable; with no per-call `AbortSignal`, the request then runs indefinitely | +| `correlationId` | Default `x-correlation-id` for every `send()`; per-call `SendOptions.correlationId` overrides | | `fetch` | Injectable `fetch` (for tests); defaults to `globalThis.fetch` | Auth lives on the constructor so the strategy can evolve without changing `send()`. +### `send(request, options?)` + +| Option | Description | +| --- | --- | +| `signal` | Optional `AbortSignal` combined with the client timeout | +| `correlationId` | Per-request trace id sent as `x-correlation-id`; overrides a client default | + +On success, `SendResponse.id` is the correlation id the API used (yours if +supplied and valid, or server-generated). Invalid values are rejected before +the request with `PostKitRequestError` code `INVALID_CORRELATION_ID`. + ### Errors Non-2xx responses and client-side failures throw `PostKitRequestError`: - `status` — HTTP status when available -- `code` — API `PostKitErrorCode`, or `'TIMEOUT'` / `'NETWORK_ERROR'` +- `code` — API `PostKitErrorCode`, or `'TIMEOUT'` / `'NETWORK_ERROR'` / `'INVALID_CORRELATION_ID'` - `correlationId` — from the error body when present ```ts diff --git a/packages/post-kit-client/src/client.spec.ts b/packages/post-kit-client/src/client.spec.ts index 3ea1063..09dea13 100644 --- a/packages/post-kit-client/src/client.spec.ts +++ b/packages/post-kit-client/src/client.spec.ts @@ -233,6 +233,141 @@ describe('PostKitClient', () => { ); }); + it('sends x-correlation-id when correlationId is supplied on send()', async () => { + let capturedHeaders: Record | undefined; + + const client = new PostKitClient({ + endpoint: 'https://postkit.example.com', + apiKey: 'pk_test_key', + fetch: async (_input, init) => { + capturedHeaders = init?.headers as Record; + return jsonResponse(200, { id: 'my-trace-01', status: 'sent' }); + }, + }); + + const result = await client.send(SEND_REQUEST, { correlationId: 'my-trace-01' }); + + assert.equal(capturedHeaders?.['x-correlation-id'], 'my-trace-01'); + assert.equal(result.id, 'my-trace-01'); + assert.equal(result.status, 'sent'); + }); + + it('does not send x-correlation-id when correlationId is omitted', async () => { + let capturedHeaders: Record | undefined; + + const client = new PostKitClient({ + endpoint: 'https://postkit.example.com', + apiKey: 'pk_test_key', + fetch: async (_input, init) => { + capturedHeaders = init?.headers as Record; + return jsonResponse(200, SEND_RESPONSE); + }, + }); + + await client.send(SEND_REQUEST); + + assert.equal(capturedHeaders?.['x-correlation-id'], undefined); + }); + + it('uses a client-level default correlationId when send() does not override it', async () => { + let capturedHeaders: Record | undefined; + + const client = new PostKitClient({ + endpoint: 'https://postkit.example.com', + apiKey: 'pk_test_key', + correlationId: 'client-default', + fetch: async (_input, init) => { + capturedHeaders = init?.headers as Record; + return jsonResponse(200, { id: 'client-default', status: 'sent' }); + }, + }); + + await client.send(SEND_REQUEST); + + assert.equal(capturedHeaders?.['x-correlation-id'], 'client-default'); + }); + + it('prefers per-request correlationId over the client default', async () => { + let capturedHeaders: Record | undefined; + + const client = new PostKitClient({ + endpoint: 'https://postkit.example.com', + apiKey: 'pk_test_key', + correlationId: 'client-default', + fetch: async (_input, init) => { + capturedHeaders = init?.headers as Record; + return jsonResponse(200, { id: 'request-trace', status: 'sent' }); + }, + }); + + await client.send(SEND_REQUEST, { correlationId: 'request-trace' }); + + assert.equal(capturedHeaders?.['x-correlation-id'], 'request-trace'); + }); + + it('rejects invalid correlationId values before the request is sent', async () => { + let fetchCalled = false; + const client = new PostKitClient({ + endpoint: 'https://postkit.example.com', + apiKey: 'pk_test_key', + fetch: async () => { + fetchCalled = true; + return jsonResponse(200, SEND_RESPONSE); + }, + }); + + const invalidValues = [ + '', + 'short', + 'a'.repeat(129), + 'has spaces', + 'has/slash', + 'has:newline\n', + ]; + + for (const correlationId of invalidValues) { + fetchCalled = false; + await assert.rejects( + () => client.send(SEND_REQUEST, { correlationId }), + (err: unknown) => { + assert.ok(err instanceof PostKitRequestError); + assert.equal(err.code, 'INVALID_CORRELATION_ID'); + assert.equal(err.status, undefined); + assert.equal(err.correlationId, undefined); + return true; + }, + ); + assert.equal(fetchCalled, false, `fetch must not run for ${JSON.stringify(correlationId)}`); + } + }); + + it('surfaces correlationId from the X-Correlation-Id header when the error body omits it', async () => { + const client = new PostKitClient({ + endpoint: 'https://postkit.example.com', + apiKey: 'pk_test_key', + fetch: async () => + new Response( + JSON.stringify({ error: 'Bad gateway', code: PostKitErrorCode.PROVIDER_FAILURE }), + { + status: 502, + headers: { + 'Content-Type': 'application/json', + 'X-Correlation-Id': 'header-corr-1', + }, + }, + ), + }); + + await assert.rejects( + () => client.send(SEND_REQUEST), + (err: unknown) => { + assert.ok(err instanceof PostKitRequestError); + assert.equal(err.correlationId, 'header-corr-1'); + return true; + }, + ); + }); + it('rethrows when the caller AbortSignal aborts the request', async () => { const controller = new AbortController(); const client = new PostKitClient({ diff --git a/packages/post-kit-client/src/client.ts b/packages/post-kit-client/src/client.ts index 566ff77..082f6d6 100644 --- a/packages/post-kit-client/src/client.ts +++ b/packages/post-kit-client/src/client.ts @@ -1,4 +1,5 @@ import type { PostKitErrorResponse, SendRequest, SendResponse } from '@singleton-sd/post-kit-types'; +import { isValidCorrelationId } from './correlation'; import { PostKitRequestError } from './errors'; const DEFAULT_TIMEOUT_MS = 30_000; @@ -20,6 +21,11 @@ export interface PostKitClientOptions { * optional per-call `AbortSignal` fires, or indefinitely if none is given). */ timeout?: number; + /** + * Default correlation ID sent as `x-correlation-id` on every `send()` call. + * Per-request `SendOptions.correlationId` overrides this value. + */ + correlationId?: string; /** Injectable `fetch` implementation (defaults to `globalThis.fetch`). */ fetch?: typeof globalThis.fetch; } @@ -27,6 +33,11 @@ export interface PostKitClientOptions { export interface SendOptions { /** Optional abort signal threaded through to `fetch`. */ signal?: AbortSignal; + /** + * Correlation ID for this request, sent as `x-correlation-id`. + * Overrides a client-level default when both are set. + */ + correlationId?: string; } /** @@ -39,6 +50,7 @@ export class PostKitClient { private readonly endpoint: string; private readonly apiKey: string; private readonly timeoutMs: number; + private readonly defaultCorrelationId: string | undefined; private readonly fetchImpl: typeof globalThis.fetch; constructor(options: PostKitClientOptions) { @@ -51,6 +63,7 @@ export class PostKitClient { this.endpoint = options.endpoint.replace(/\/+$/, ''); this.apiKey = options.apiKey; this.timeoutMs = options.timeout ?? DEFAULT_TIMEOUT_MS; + this.defaultCorrelationId = options.correlationId; this.fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis); } @@ -60,14 +73,24 @@ export class PostKitClient { async send(request: SendRequest, options?: SendOptions): Promise { const url = `${this.endpoint}/emails/send`; const callerSignal = options?.signal; + const correlationId = options?.correlationId ?? this.defaultCorrelationId; + + if (correlationId !== undefined) { + this.assertValidCorrelationId(correlationId); + } try { + const headers: Record = { + Authorization: `Bearer ${this.apiKey}`, + 'Content-Type': 'application/json', + }; + if (correlationId !== undefined) { + headers['x-correlation-id'] = correlationId; + } + const response = await this.fetchImpl(url, { method: 'POST', - headers: { - Authorization: `Bearer ${this.apiKey}`, - 'Content-Type': 'application/json', - }, + headers, body: JSON.stringify(request), signal: this.combineSignals(callerSignal), }); @@ -150,9 +173,18 @@ export class PostKitClient { message, code: body.code ?? `HTTP_${response.status}`, status: response.status, - correlationId: body.correlationId, + correlationId: body.correlationId ?? response.headers.get('X-Correlation-Id') ?? undefined, }); } + + private assertValidCorrelationId(correlationId: string): void { + if (!isValidCorrelationId(correlationId)) { + throw new PostKitRequestError({ + message: 'Invalid correlation ID', + code: 'INVALID_CORRELATION_ID', + }); + } + } } function isAbortError(err: unknown): boolean { diff --git a/packages/post-kit-client/src/correlation.ts b/packages/post-kit-client/src/correlation.ts new file mode 100644 index 0000000..bbce906 --- /dev/null +++ b/packages/post-kit-client/src/correlation.ts @@ -0,0 +1,12 @@ +/** Valid correlation ID: 8–128 alphanumeric / hyphen / underscore characters. */ +const VALID_CORRELATION_ID = /^[a-zA-Z0-9_-]{8,128}$/; + +/** + * Validate a caller-supplied correlation ID before it is sent as `x-correlation-id`. + * + * Rules match the API's {@link resolveCorrelationId}: 8–128 chars, alphanumeric, + * hyphen, and underscore only. + */ +export function isValidCorrelationId(value: string): boolean { + return VALID_CORRELATION_ID.test(value); +} diff --git a/packages/post-kit-client/src/errors.ts b/packages/post-kit-client/src/errors.ts index b21679c..5b46625 100644 --- a/packages/post-kit-client/src/errors.ts +++ b/packages/post-kit-client/src/errors.ts @@ -3,17 +3,17 @@ import type { PostKitErrorCode } from '@singleton-sd/post-kit-types'; /** * Error thrown by {@link PostKitClient} for HTTP, timeout, and network failures. * - * `code` is a {@link PostKitErrorCode} from the API, or `'TIMEOUT'` / `'NETWORK_ERROR'` - * for client-side failures. + * `code` is a {@link PostKitErrorCode} from the API, or `'TIMEOUT'` / `'NETWORK_ERROR'` / + * `'INVALID_CORRELATION_ID'` for client-side failures. */ export class PostKitRequestError extends Error { readonly status: number | undefined; - readonly code: PostKitErrorCode | 'TIMEOUT' | 'NETWORK_ERROR' | string; + readonly code: PostKitErrorCode | 'TIMEOUT' | 'NETWORK_ERROR' | 'INVALID_CORRELATION_ID' | string; readonly correlationId: string | undefined; constructor(options: { message: string; - code: PostKitErrorCode | 'TIMEOUT' | 'NETWORK_ERROR' | string; + code: PostKitErrorCode | 'TIMEOUT' | 'NETWORK_ERROR' | 'INVALID_CORRELATION_ID' | string; status?: number; correlationId?: string; cause?: unknown; From fff0cbdd1d3f25074812e256eda3bfb58bfbb83e Mon Sep 17 00:00:00 2001 From: Pato Perpetua Date: Sun, 30 Aug 2026 13:35:00 +1000 Subject: [PATCH 2/2] docs: limit correlation ID guarantee to HTTP error responses Addresses CodeRabbit review on #77. Co-authored-by: Cursor --- docs/guides/api-quickstart.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/guides/api-quickstart.md b/docs/guides/api-quickstart.md index 0ade7de..7aaf975 100644 --- a/docs/guides/api-quickstart.md +++ b/docs/guides/api-quickstart.md @@ -137,9 +137,10 @@ console.log(result.id, result.status); // "my-trace-01 sent" ``` Pass `correlationId` on `send()` (or as a client constructor default) to send -`x-correlation-id`. Omit it and the API generates one; `result.id` on success -and `PostKitRequestError.correlationId` on failure always carry the id the -server used. +`x-correlation-id`. Omit it and the API generates one. `result.id` on success +contains the server correlation ID. On HTTP failures, +`PostKitRequestError.correlationId` contains it when the error body or +`X-Correlation-Id` response header provides it. ### Constructor options @@ -238,9 +239,10 @@ on 502 or 500. ### Logging for support -Log `code`, `status`, and `correlationId` on every failure, and `id` on every -success — both are the same correlation id the API logs, and it is the only -handle that ties your request to the server-side trace. Never log the API key, +Log `code`, `status`, and `correlationId` (when present) on HTTP failures, and +`id` on every success — both are the same correlation id the API logs when the +server handled the request, and it is the only handle that ties your request to +the server-side trace. Never log the API key, the rendered email, or a reset/verification URL. ## Where to go next