diff --git a/.env.example b/.env.example index 33319ab..d59d2b9 100644 --- a/.env.example +++ b/.env.example @@ -46,3 +46,7 @@ ORIGINS= TENANT_KEY_MAP= TEMPLATE_STORAGE_ACCOUNT= TEMPLATE_STORAGE_CONTAINER=templates +# Send idempotency ledger (defaults shown). Same account as templates is fine. +# IDEMPOTENCY_STORAGE_ACCOUNT= # falls back to TEMPLATE_STORAGE_ACCOUNT +# IDEMPOTENCY_STORAGE_CONTAINER=idempotency +# IDEMPOTENCY_TTL_MS=86400000 diff --git a/apps/api/src/config/app-configuration.ts b/apps/api/src/config/app-configuration.ts index 484ec9c..721c587 100644 --- a/apps/api/src/config/app-configuration.ts +++ b/apps/api/src/config/app-configuration.ts @@ -30,6 +30,9 @@ export const APP_CONFIGURATION_ENVIRONMENT_KEYS: Readonly 'app:email:validation:requireBimiSvg': 'EMAIL_VALIDATION_REQUIRE_BIMI_SVG', 'app:templates:storageAccount': 'TEMPLATE_STORAGE_ACCOUNT', 'app:templates:storageContainer': 'TEMPLATE_STORAGE_CONTAINER', + 'app:idempotency:storageAccount': 'IDEMPOTENCY_STORAGE_ACCOUNT', + 'app:idempotency:storageContainer': 'IDEMPOTENCY_STORAGE_CONTAINER', + 'app:idempotency:ttlMs': 'IDEMPOTENCY_TTL_MS', 'secret:forwardemail-api-key': 'FORWARD_EMAIL_TOKEN', }; diff --git a/apps/api/src/functions/send.idempotency.spec.ts b/apps/api/src/functions/send.idempotency.spec.ts new file mode 100644 index 0000000..5d23986 --- /dev/null +++ b/apps/api/src/functions/send.idempotency.spec.ts @@ -0,0 +1,279 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import type { HttpRequest, InvocationContext } from '@azure/functions'; +import type { + EmailProvider, + EmailSendRequest, + EmailSendResult, +} from '@singleton-sd/post-kit-email'; +import { + PostKitErrorCode, + TEMPLATE_SCHEMA_VERSION, + type CompiledTemplate, + type TenantContext, +} from '@singleton-sd/post-kit-types'; +import { MemoryIdempotencyStore } from '../idempotency'; +import { resetSendRateLimiter } from '../contact-rate-limit'; +import type { TenantResolver } from '../tenant'; +import type { TemplateStore } from '../templates'; +import { createSendHandler } from './send'; + +const TENANT: TenantContext = { tenantId: 'inkads', environment: 'development' }; + +const COMPILED: CompiledTemplate = { + templateHtml: '

Hello {{name}}

', + metadata: { + key: 'marketing.contact-us', + name: 'Contact Us', + subject: 'Hi {{name}}', + variables: ['name'], + schemaVersion: TEMPLATE_SCHEMA_VERSION, + }, + manifest: { + key: 'marketing.contact-us', + schemaVersion: TEMPLATE_SCHEMA_VERSION, + compiledAt: '2026-01-01T00:00:00.000Z', + sourceCommit: '', + variables: ['name'], + contentHash: 'abc', + }, +}; + +function fakeRequest(options: { headers?: Record; json?: unknown }): HttpRequest { + const headers = new Headers(options.headers); + const jsonBody = options.json ?? null; + const textBody = jsonBody === null ? '' : JSON.stringify(jsonBody); + return { + method: 'POST', + headers: { get: (name: string) => headers.get(name) }, + json: async () => jsonBody, + text: async () => textBody, + } as unknown as HttpRequest; +} + +function fakeContext(): InvocationContext { + return { error: () => undefined } as unknown as InvocationContext; +} + +function fakeResolver(tenant: TenantContext = TENANT): TenantResolver { + return { resolve: async () => tenant }; +} + +function fakeStore(): TemplateStore { + return { load: async () => COMPILED }; +} + +function fakeProvider(capture?: EmailSendRequest[]): EmailProvider { + return { + name: 'development', + isConfigured: () => true, + send: async (request): Promise => { + capture?.push(request); + return { providerMessageId: 'msg-1', accepted: true }; + }, + }; +} + +function stubSender() { + return { + resolveTenantEmailConfig: async () => ({ + fromAddress: 'noreply@example.com', + fromDisplayName: 'PostKit', + }), + }; +} + +function validBody() { + return { + template: 'marketing.contact-us', + to: 'user@example.com', + variables: { name: 'Ada' }, + }; +} + +describe('sendHandler idempotency', () => { + it('without Idempotency-Key keeps at-least-once behaviour (provider called each time)', async () => { + resetSendRateLimiter(); + const sent: EmailSendRequest[] = []; + const handler = createSendHandler({ + tenantResolver: fakeResolver(), + templateStore: fakeStore(), + emailProvider: fakeProvider(sent), + idempotencyStore: new MemoryIdempotencyStore(), + ...stubSender(), + }); + + assert.equal((await handler(fakeRequest({ json: validBody() }), fakeContext())).status, 200); + assert.equal((await handler(fakeRequest({ json: validBody() }), fakeContext())).status, 200); + assert.equal(sent.length, 2); + }); + + it('replays a completed request without a second provider call', async () => { + resetSendRateLimiter(); + const sent: EmailSendRequest[] = []; + const store = new MemoryIdempotencyStore(); + const handler = createSendHandler({ + tenantResolver: fakeResolver(), + templateStore: fakeStore(), + emailProvider: fakeProvider(sent), + idempotencyStore: store, + ...stubSender(), + }); + + const first = await handler( + fakeRequest({ + headers: { + authorization: 'Bearer tok', + 'idempotency-key': 'retry-abc', + 'x-correlation-id': 'corr-first-01', + }, + json: validBody(), + }), + fakeContext(), + ); + assert.equal(first.status, 200); + assert.deepEqual(first.jsonBody, { id: 'corr-first-01', status: 'sent' }); + assert.equal(sent.length, 1); + + const second = await handler( + fakeRequest({ + headers: { + authorization: 'Bearer tok', + 'idempotency-key': 'retry-abc', + 'x-correlation-id': 'corr-second-02', + }, + json: validBody(), + }), + fakeContext(), + ); + assert.equal(second.status, 200); + assert.deepEqual(second.jsonBody, { id: 'corr-first-01', status: 'sent' }); + assert.equal(sent.length, 1); + }); + + it('returns IDEMPOTENCY_IN_PROGRESS while the first request is in flight', async () => { + resetSendRateLimiter(); + const store = new MemoryIdempotencyStore(); + let releaseSend!: () => void; + const sendGate = new Promise((resolve) => { + releaseSend = resolve; + }); + + const handler = createSendHandler({ + tenantResolver: fakeResolver(), + templateStore: fakeStore(), + emailProvider: { + name: 'development', + isConfigured: () => true, + send: async () => { + await sendGate; + return { providerMessageId: 'msg-1', accepted: true }; + }, + }, + idempotencyStore: store, + ...stubSender(), + }); + + const firstPromise = handler( + fakeRequest({ + headers: { 'idempotency-key': 'inflight-1', 'x-correlation-id': 'corr-inflight-a' }, + json: validBody(), + }), + fakeContext(), + ); + + // Allow the first handler to claim and block inside provider.send. + await new Promise((r) => setTimeout(r, 20)); + + const concurrent = await handler( + fakeRequest({ + headers: { 'idempotency-key': 'inflight-1', 'x-correlation-id': 'corr-inflight-b' }, + json: validBody(), + }), + fakeContext(), + ); + + assert.equal(concurrent.status, 409); + assert.equal( + (concurrent.jsonBody as { code: string }).code, + PostKitErrorCode.IDEMPOTENCY_IN_PROGRESS, + ); + + releaseSend(); + const first = await firstPromise; + assert.equal(first.status, 200); + }); + + it('treats the same key from a different tenant as a distinct request', async () => { + resetSendRateLimiter(); + const sent: EmailSendRequest[] = []; + const store = new MemoryIdempotencyStore(); + const other: TenantContext = { tenantId: 'other', environment: 'development' }; + + const handlerA = createSendHandler({ + tenantResolver: fakeResolver(TENANT), + templateStore: fakeStore(), + emailProvider: fakeProvider(sent), + idempotencyStore: store, + ...stubSender(), + }); + const handlerB = createSendHandler({ + tenantResolver: fakeResolver(other), + templateStore: fakeStore(), + emailProvider: fakeProvider(sent), + idempotencyStore: store, + ...stubSender(), + }); + + assert.equal( + ( + await handlerA( + fakeRequest({ headers: { 'idempotency-key': 'shared-key' }, json: validBody() }), + fakeContext(), + ) + ).status, + 200, + ); + assert.equal( + ( + await handlerB( + fakeRequest({ headers: { 'idempotency-key': 'shared-key' }, json: validBody() }), + fakeContext(), + ) + ).status, + 200, + ); + assert.equal(sent.length, 2); + }); + + it('rejects unsafe Idempotency-Key values before touching the store', async () => { + resetSendRateLimiter(); + let beginCalled = false; + const handler = createSendHandler({ + tenantResolver: fakeResolver(), + templateStore: fakeStore(), + emailProvider: fakeProvider(), + idempotencyStore: { + begin: async () => { + beginCalled = true; + return { outcome: 'claimed' }; + }, + complete: async () => undefined, + release: async () => undefined, + }, + ...stubSender(), + }); + + const response = await handler( + fakeRequest({ + headers: { 'idempotency-key': 'bad key with spaces' }, + json: validBody(), + }), + fakeContext(), + ); + + assert.equal(response.status, 400); + assert.match((response.jsonBody as { error: string }).error, /invalid characters/i); + assert.equal(beginCalled, false); + }); +}); diff --git a/apps/api/src/functions/send.ts b/apps/api/src/functions/send.ts index 6bf3f5f..df965f5 100644 --- a/apps/api/src/functions/send.ts +++ b/apps/api/src/functions/send.ts @@ -17,6 +17,13 @@ import { } from '@singleton-sd/post-kit-types'; import { ensureAppConfiguration } from '../config/app-configuration'; import { getSendRateLimiter, sendRateLimitKey } from '../contact-rate-limit'; +import { + BlobIdempotencyStore, + IDEMPOTENCY_KEY_HEADER, + IdempotencyStoreError, + validateIdempotencyKey, + type IdempotencyStore, +} from '../idempotency'; import { getSendSizeLimits, validateRequestBodySize, validateVariablesSize } from '../send-limits'; import { createLogger, hashRecipient, resolveCorrelationId, type Logger } from '../telemetry'; import { @@ -55,6 +62,14 @@ export interface SendHandlerDependencies { resolveTenantEmailConfig?: ( tenant: TenantContext, ) => Promise | ResolvedTenantEmailConfig; + /** + * Out-of-process idempotency ledger. When omitted, production resolves a + * Blob store from env; unit tests inject `MemoryIdempotencyStore` or leave + * unset when no Idempotency-Key header is sent. + */ + idempotencyStore?: IdempotencyStore; + /** Lazy factory for production (loads App Configuration first). */ + createIdempotencyStore?: () => Promise; createLogger?: typeof createLogger; } @@ -79,6 +94,7 @@ export function createDefaultSendDependencies( createEmailProvider: (options) => createEmailProvider(process.env, options), resolveBranding: async () => ({}), resolveTenantEmailConfig: (tenant) => resolveTenantEmailConfig(tenant), + createIdempotencyStore: () => BlobIdempotencyStore.fromEnv(), }; } @@ -177,6 +193,23 @@ export function createSendHandler(deps: SendHandlerDependencies) { ); } + const rawIdempotencyKey = request.headers.get(IDEMPOTENCY_KEY_HEADER); + let idempotencyKey: string | undefined; + if (rawIdempotencyKey !== null && rawIdempotencyKey !== undefined) { + // Header present (including empty) — validate before any storage access. + const validated = validateIdempotencyKey(rawIdempotencyKey); + if (!validated.ok) { + return errorResponse( + 400, + PostKitErrorCode.INVALID_RECIPIENT, + validated.error, + 'validation_error', + { failureCategory: 'invalid_idempotency_key' }, + ); + } + idempotencyKey = validated.key; + } + const sizeLimits = getSendSizeLimits(); let rawBody: string; try { @@ -274,27 +307,126 @@ export function createSendHandler(deps: SendHandlerDependencies) { ? { apiToken: tenantEmailConfig.providerApiToken } : undefined, ); - const result = await provider.send({ - to: sendRequest.to, - from: tenantEmailConfig.fromAddress, - fromName: tenantEmailConfig.fromDisplayName, - replyTo: tenantEmailConfig.replyTo, - subject, - html, - correlationId, - }); - const durationMs = Date.now() - startMs; - logger.info('send.request.completed', { - outcome: 'sent', - durationMs, - providerMessageId: result.providerMessageId, - ...logContext(), - }); + let idempotencyStore: IdempotencyStore | undefined; + let idempotencyClaimed = false; + if (idempotencyKey) { + try { + idempotencyStore = + deps.idempotencyStore ?? + (deps.createIdempotencyStore ? await deps.createIdempotencyStore() : undefined); + } catch (err) { + if (err instanceof IdempotencyStoreError) { + return errorResponse(503, err.code, err.message, 'failed'); + } + context.error('idempotency store init failed', { + name: err instanceof Error ? err.name : 'Error', + correlationId, + }); + return errorResponse( + 503, + PostKitErrorCode.STORAGE_FAILURE, + 'Idempotency storage is temporarily unavailable.', + ); + } + if (!idempotencyStore) { + return errorResponse( + 503, + PostKitErrorCode.STORAGE_FAILURE, + 'Idempotency storage is not configured.', + ); + } + + let beginResult; + try { + beginResult = await idempotencyStore.begin(tenant, idempotencyKey); + } catch (err) { + if (err instanceof IdempotencyStoreError) { + return errorResponse(503, err.code, err.message, 'failed'); + } + throw err; + } + + if (beginResult.outcome === 'replay') { + const durationMs = Date.now() - startMs; + logger.info('send.request.completed', { + outcome: 'sent', + durationMs, + ...logContext(), + }); + return { + status: 200, + headers: { + ...headers, + 'X-Correlation-Id': beginResult.response.id, + }, + jsonBody: beginResult.response, + }; + } + + if (beginResult.outcome === 'in_progress') { + return errorResponse( + 409, + PostKitErrorCode.IDEMPOTENCY_IN_PROGRESS, + 'A request with this Idempotency-Key is already in progress for this tenant.', + 'failed', + { failureCategory: 'idempotency_in_progress' }, + ); + } + + idempotencyClaimed = true; + } + + try { + const result = await provider.send({ + to: sendRequest.to, + from: tenantEmailConfig.fromAddress, + fromName: tenantEmailConfig.fromDisplayName, + replyTo: tenantEmailConfig.replyTo, + subject, + html, + correlationId, + }); - const response: SendResponse = { id: correlationId, status: 'sent' }; - return { status: 200, headers, jsonBody: response }; + const response: SendResponse = { id: correlationId, status: 'sent' }; + + if (idempotencyClaimed && idempotencyStore && idempotencyKey) { + try { + await idempotencyStore.complete(tenant, idempotencyKey, response); + } catch (err) { + // Provider already accepted the message — return success and log. + // The in-progress claim remains until TTL so replays stay safe. + context.error('idempotency complete failed', { + name: err instanceof Error ? err.name : 'Error', + correlationId, + }); + } + } + + const durationMs = Date.now() - startMs; + logger.info('send.request.completed', { + outcome: 'sent', + durationMs, + providerMessageId: result.providerMessageId, + ...logContext(), + }); + + return { status: 200, headers, jsonBody: response }; + } catch (sendError) { + if (idempotencyClaimed && idempotencyStore && idempotencyKey) { + try { + await idempotencyStore.release(tenant, idempotencyKey); + } catch { + // Prefer the original send failure; release is best-effort. + } + } + throw sendError; + } } catch (error) { + if (error instanceof IdempotencyStoreError) { + return errorResponse(503, error.code, error.message, 'failed'); + } + if (error instanceof TenantEmailConfigError) { return errorResponse(503, error.code, error.message, 'failed', { failureCategory: 'tenant_config_not_found', @@ -374,6 +506,8 @@ function failureCategoryFromErrorCode( return 'tenant_config_not_found'; case PostKitErrorCode.PROVIDER_FAILURE: return 'provider_failure'; + case PostKitErrorCode.IDEMPOTENCY_IN_PROGRESS: + return 'idempotency_in_progress'; default: return 'unknown'; } diff --git a/apps/api/src/idempotency/blob-idempotency-store.spec.ts b/apps/api/src/idempotency/blob-idempotency-store.spec.ts new file mode 100644 index 0000000..a78044a --- /dev/null +++ b/apps/api/src/idempotency/blob-idempotency-store.spec.ts @@ -0,0 +1,101 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import type { TenantContext } from '@singleton-sd/post-kit-types'; +import { BlobIdempotencyStore } from './blob-idempotency-store'; + +const TENANT: TenantContext = { tenantId: 'inkads', environment: 'development' }; + +type BlobState = { + content?: string; +}; + +/** + * Minimal BlockBlobClient / ContainerClient / BlobServiceClient fakes for + * conditional-create + download + delete behaviour. + */ +function createFakeBlobClient() { + const blobs = new Map(); + + const client = { + getContainerClient: () => ({ + getBlockBlobClient: (path: string) => ({ + upload: async ( + body: Buffer, + _length: number, + options?: { conditions?: { ifNoneMatch?: string } }, + ) => { + const existing = blobs.get(path); + if (options?.conditions?.ifNoneMatch === '*' && existing?.content !== undefined) { + const err = Object.assign(new Error('BlobAlreadyExists'), { + statusCode: 409, + code: 'BlobAlreadyExists', + }); + throw err; + } + blobs.set(path, { content: body.toString('utf-8') }); + }, + download: async () => { + const existing = blobs.get(path); + if (!existing?.content) { + throw Object.assign(new Error('BlobNotFound'), { + statusCode: 404, + code: 'BlobNotFound', + }); + } + const { Readable } = await import('node:stream'); + return { readableStreamBody: Readable.from([existing.content]) }; + }, + deleteIfExists: async () => { + blobs.delete(path); + return { succeeded: true }; + }, + }), + }), + }; + + return { client, blobs }; +} + +describe('BlobIdempotencyStore', () => { + it('claims with If-None-Match, completes, and replays without a second claim', async () => { + const { client } = createFakeBlobClient(); + const store = new BlobIdempotencyStore({ + storageAccount: 'test', + container: 'idempotency', + client: client as never, + ttlMs: 60_000, + }); + + assert.deepEqual(await store.begin(TENANT, 'k1'), { outcome: 'claimed' }); + const response = { id: 'corr-1', status: 'sent' as const }; + await store.complete(TENANT, 'k1', response); + assert.deepEqual(await store.begin(TENANT, 'k1'), { outcome: 'replay', response }); + }); + + it('returns in_progress when the blob already exists as in_progress', async () => { + const { client } = createFakeBlobClient(); + const store = new BlobIdempotencyStore({ + storageAccount: 'test', + container: 'idempotency', + client: client as never, + ttlMs: 60_000, + }); + + assert.deepEqual(await store.begin(TENANT, 'k1'), { outcome: 'claimed' }); + assert.deepEqual(await store.begin(TENANT, 'k1'), { outcome: 'in_progress' }); + }); + + it('releases an in-progress blob so a later begin can claim', async () => { + const { client } = createFakeBlobClient(); + const store = new BlobIdempotencyStore({ + storageAccount: 'test', + container: 'idempotency', + client: client as never, + ttlMs: 60_000, + }); + + await store.begin(TENANT, 'k1'); + await store.release(TENANT, 'k1'); + assert.deepEqual(await store.begin(TENANT, 'k1'), { outcome: 'claimed' }); + }); +}); diff --git a/apps/api/src/idempotency/blob-idempotency-store.ts b/apps/api/src/idempotency/blob-idempotency-store.ts new file mode 100644 index 0000000..328f38d --- /dev/null +++ b/apps/api/src/idempotency/blob-idempotency-store.ts @@ -0,0 +1,253 @@ +import { createHash } from 'node:crypto'; +import type { BlobServiceClient, ContainerClient } from '@azure/storage-blob'; +import { BlobServiceClient as AzureBlobServiceClient } from '@azure/storage-blob'; +import { DefaultAzureCredential } from '@azure/identity'; +import type { SendResponse, TenantContext } from '@singleton-sd/post-kit-types'; +import { PostKitErrorCode } from '@singleton-sd/post-kit-types'; +import { ensureAppConfiguration } from '../config/app-configuration'; +import { + buildIdempotencyRecord, + isExpired, + resolveIdempotencyTtlMs, + type IdempotencyBeginResult, + type IdempotencyRecord, + type IdempotencyStore, +} from './idempotency-store'; + +/** + * Error thrown when the idempotency ledger cannot be read or written. + */ +export class IdempotencyStoreError extends Error { + readonly code: PostKitErrorCode; + + constructor(message: string, code: PostKitErrorCode = PostKitErrorCode.STORAGE_FAILURE) { + super(message); + this.name = 'IdempotencyStoreError'; + this.code = code; + } +} + +export interface BlobIdempotencyStoreOptions { + storageAccount: string; + container: string; + credential?: InstanceType; + client?: BlobServiceClient; + ttlMs?: number; +} + +/** + * Azure Blob Storage idempotency ledger. + * + * Blob path: + * tenants/{tenantId}/{environment}/idempotency/{sha256(key)}.json + * + * Why Blob (not Table): the API already depends on `@azure/storage-blob` and + * `DefaultAzureCredential` for templates; a small JSON blob per key needs no + * new package, supports conditional create (`If-None-Match: *`) for claim + * races, and stores only the non-sensitive fields required for replay. + * + * TTL is enforced on read (expired blobs are ignored and may be overwritten). + * Soft delete / lifecycle rules can reclaim bytes; see docs/architecture/send-idempotency.md. + */ +export class BlobIdempotencyStore implements IdempotencyStore { + private readonly client: BlobServiceClient; + private readonly container: string; + private readonly ttlMs: number; + + constructor(options: BlobIdempotencyStoreOptions) { + this.container = options.container; + this.ttlMs = options.ttlMs ?? resolveIdempotencyTtlMs(); + + if (options.client) { + this.client = options.client; + } else { + const credential = options.credential ?? new DefaultAzureCredential(); + const url = `https://${options.storageAccount}.blob.core.windows.net`; + this.client = new AzureBlobServiceClient(url, credential); + } + } + + static async fromEnv( + dependencies?: Parameters[0], + ): Promise { + await ensureAppConfiguration(dependencies); + + const storageAccount = + process.env['IDEMPOTENCY_STORAGE_ACCOUNT'] ?? process.env['TEMPLATE_STORAGE_ACCOUNT']; + const container = process.env['IDEMPOTENCY_STORAGE_CONTAINER'] ?? 'idempotency'; + + if (!storageAccount) { + throw new Error( + 'Missing required environment variable: IDEMPOTENCY_STORAGE_ACCOUNT (or TEMPLATE_STORAGE_ACCOUNT)', + ); + } + + return new BlobIdempotencyStore({ + storageAccount, + container, + ttlMs: resolveIdempotencyTtlMs(), + }); + } + + async begin(tenant: TenantContext, key: string): Promise { + const containerClient = this.client.getContainerClient(this.container); + const blob = containerClient.getBlockBlobClient(blobPath(tenant, key)); + const record = buildIdempotencyRecord(tenant, key, 'in_progress', this.ttlMs); + const body = Buffer.from(JSON.stringify(record), 'utf-8'); + + try { + await blob.upload(body, body.length, { + blobHTTPHeaders: { blobContentType: 'application/json' }, + conditions: { ifNoneMatch: '*' }, + }); + return { outcome: 'claimed' }; + } catch (err: unknown) { + if (!isConflictError(err)) { + throw new IdempotencyStoreError( + 'Failed to claim idempotency key in storage.', + PostKitErrorCode.STORAGE_FAILURE, + ); + } + } + + const existing = await this.readRecord(blob); + if (!existing || isExpired(existing)) { + // Expired or unreadable — overwrite unconditionally and claim. + try { + await blob.upload(body, body.length, { + blobHTTPHeaders: { blobContentType: 'application/json' }, + }); + return { outcome: 'claimed' }; + } catch { + throw new IdempotencyStoreError( + 'Failed to reclaim expired idempotency key in storage.', + PostKitErrorCode.STORAGE_FAILURE, + ); + } + } + + if (existing.status === 'completed' && existing.response) { + return { outcome: 'replay', response: existing.response }; + } + return { outcome: 'in_progress' }; + } + + async complete(tenant: TenantContext, key: string, response: SendResponse): Promise { + const containerClient = this.client.getContainerClient(this.container); + const blob = containerClient.getBlockBlobClient(blobPath(tenant, key)); + const record = buildIdempotencyRecord(tenant, key, 'completed', this.ttlMs, response); + const body = Buffer.from(JSON.stringify(record), 'utf-8'); + try { + await blob.upload(body, body.length, { + blobHTTPHeaders: { blobContentType: 'application/json' }, + }); + } catch { + throw new IdempotencyStoreError( + 'Failed to persist completed idempotency record.', + PostKitErrorCode.STORAGE_FAILURE, + ); + } + } + + async release(tenant: TenantContext, key: string): Promise { + const containerClient = this.client.getContainerClient(this.container); + const blob = containerClient.getBlockBlobClient(blobPath(tenant, key)); + try { + const existing = await this.readRecord(blob); + if (!existing || existing.status === 'completed') return; + await blob.deleteIfExists(); + } catch { + throw new IdempotencyStoreError( + 'Failed to release idempotency claim in storage.', + PostKitErrorCode.STORAGE_FAILURE, + ); + } + } + + private async readRecord( + blob: ReturnType, + ): Promise { + try { + const download = await blob.download(); + const text = await streamToString(download.readableStreamBody); + return parseRecord(text); + } catch (err: unknown) { + if (isNotFoundError(err)) return undefined; + throw new IdempotencyStoreError( + 'Failed to read idempotency record from storage.', + PostKitErrorCode.STORAGE_FAILURE, + ); + } + } +} + +function blobPath(tenant: TenantContext, key: string): string { + const digest = createHash('sha256').update(key, 'utf8').digest('hex'); + return `tenants/${tenant.tenantId}/${tenant.environment}/idempotency/${digest}.json`; +} + +function parseRecord(json: string): IdempotencyRecord | undefined { + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch { + return undefined; + } + if (typeof parsed !== 'object' || parsed === null) return undefined; + const obj = parsed as Record; + if (typeof obj['key'] !== 'string') return undefined; + if (typeof obj['tenantId'] !== 'string') return undefined; + if (typeof obj['environment'] !== 'string') return undefined; + if (obj['status'] !== 'in_progress' && obj['status'] !== 'completed') return undefined; + if (typeof obj['createdAt'] !== 'string') return undefined; + if (typeof obj['expiresAt'] !== 'string') return undefined; + + let response: SendResponse | undefined; + if (obj['response'] !== undefined) { + const r = obj['response']; + if (typeof r !== 'object' || r === null) return undefined; + const resp = r as Record; + if (typeof resp['id'] !== 'string' || resp['status'] !== 'sent') return undefined; + response = { id: resp['id'], status: 'sent' }; + } + + return { + key: obj['key'], + tenantId: obj['tenantId'], + environment: obj['environment'], + status: obj['status'], + response, + createdAt: obj['createdAt'], + expiresAt: obj['expiresAt'], + }; +} + +function isConflictError(err: unknown): boolean { + if (typeof err !== 'object' || err === null) return false; + const e = err as Record; + return ( + e['statusCode'] === 409 || + e['code'] === 'BlobAlreadyExists' || + e['errorCode'] === 'BlobAlreadyExists' + ); +} + +function isNotFoundError(err: unknown): boolean { + if (typeof err !== 'object' || err === null) return false; + const e = err as Record; + return ( + e['statusCode'] === 404 || e['code'] === 'BlobNotFound' || e['errorCode'] === 'BlobNotFound' + ); +} + +async function streamToString(stream: NodeJS.ReadableStream | undefined | null): Promise { + if (!stream) return ''; + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + stream.on('data', (chunk: Buffer | string) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8'))); + stream.on('error', reject); + }); +} diff --git a/apps/api/src/idempotency/idempotency-key.spec.ts b/apps/api/src/idempotency/idempotency-key.spec.ts new file mode 100644 index 0000000..f89f3bf --- /dev/null +++ b/apps/api/src/idempotency/idempotency-key.spec.ts @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { validateIdempotencyKey } from './idempotency-key'; + +describe('validateIdempotencyKey', () => { + it('accepts allowlisted keys up to 128 characters', () => { + assert.deepEqual(validateIdempotencyKey('retry-1'), { ok: true, key: 'retry-1' }); + assert.deepEqual(validateIdempotencyKey('a'.repeat(128)), { + ok: true, + key: 'a'.repeat(128), + }); + assert.deepEqual(validateIdempotencyKey('uuid:v4_or.tilde~ok'), { + ok: true, + key: 'uuid:v4_or.tilde~ok', + }); + }); + + it('trims surrounding whitespace', () => { + assert.deepEqual(validateIdempotencyKey(' key-1 '), { ok: true, key: 'key-1' }); + }); + + it('rejects empty and whitespace-only values', () => { + assert.equal(validateIdempotencyKey('').ok, false); + assert.equal(validateIdempotencyKey(' ').ok, false); + }); + + it('rejects oversized keys before storage', () => { + const result = validateIdempotencyKey('a'.repeat(129)); + assert.equal(result.ok, false); + if (!result.ok) assert.match(result.error, /128/); + }); + + it('rejects unsafe charset', () => { + assert.equal(validateIdempotencyKey('has space').ok, false); + assert.equal(validateIdempotencyKey('path/../x').ok, false); + assert.equal(validateIdempotencyKey('null\0byte').ok, false); + }); +}); diff --git a/apps/api/src/idempotency/idempotency-key.ts b/apps/api/src/idempotency/idempotency-key.ts new file mode 100644 index 0000000..20ed51e --- /dev/null +++ b/apps/api/src/idempotency/idempotency-key.ts @@ -0,0 +1,40 @@ +/** + * Idempotency-Key header validation for POST /emails/send. + * + * Keys are consumer-generated; we allowlist characters so the value is safe + * to hash into a blob path and reject oversized / empty values before storage. + */ + +/** 1–128 chars: alphanumeric, underscore, hyphen, dot, colon, tilde. */ +const VALID_IDEMPOTENCY_KEY = /^[A-Za-z0-9._:~-]{1,128}$/; + +export const IDEMPOTENCY_KEY_HEADER = 'idempotency-key'; + +export type IdempotencyKeyValidation = { ok: true; key: string } | { ok: false; error: string }; + +/** + * Validate a raw Idempotency-Key header value. + * + * Missing / null / undefined means the caller omitted the header — callers + * should treat that as "no idempotency" rather than calling this helper. + * Empty string and whitespace-only values are rejected. + */ +export function validateIdempotencyKey(raw: string): IdempotencyKeyValidation { + const key = raw.trim(); + if (!key) { + return { ok: false, error: 'Idempotency-Key header must not be empty.' }; + } + if (key.length > 128) { + return { + ok: false, + error: 'Idempotency-Key header must be at most 128 characters.', + }; + } + if (!VALID_IDEMPOTENCY_KEY.test(key)) { + return { + ok: false, + error: 'Idempotency-Key header contains invalid characters. Use 1–128 of [A-Za-z0-9._:~-].', + }; + } + return { ok: true, key }; +} diff --git a/apps/api/src/idempotency/idempotency-store.ts b/apps/api/src/idempotency/idempotency-store.ts new file mode 100644 index 0000000..f55b29d --- /dev/null +++ b/apps/api/src/idempotency/idempotency-store.ts @@ -0,0 +1,84 @@ +import type { SendResponse, TenantContext } from '@singleton-sd/post-kit-types'; + +/** Default retention for idempotency records (24 hours). */ +export const DEFAULT_IDEMPOTENCY_TTL_MS = 24 * 60 * 60 * 1000; + +export type IdempotencyStatus = 'in_progress' | 'completed'; + +/** + * Persisted record. Never stores recipients, variables, or rendered bodies. + */ +export interface IdempotencyRecord { + /** Consumer-supplied Idempotency-Key (validated). */ + key: string; + tenantId: string; + environment: string; + status: IdempotencyStatus; + /** Present when status is `completed`. */ + response?: SendResponse; + /** ISO-8601 creation time. */ + createdAt: string; + /** ISO-8601 expiry; expired records are treated as absent. */ + expiresAt: string; +} + +export type IdempotencyBeginResult = + | { outcome: 'claimed' } + | { outcome: 'replay'; response: SendResponse } + | { outcome: 'in_progress' }; + +/** + * Out-of-process idempotency ledger for send. + * + * Implementations must scope keys by tenant (and environment) so the same + * consumer key from two tenants never collides. + */ +export interface IdempotencyStore { + /** + * Claim the key for an in-flight send, or return an existing outcome. + * Expired records are ignored (treated as absent). + */ + begin(tenant: TenantContext, key: string): Promise; + + /** Mark the key completed and store the success response for replays. */ + complete(tenant: TenantContext, key: string, response: SendResponse): Promise; + + /** + * Drop an in-progress claim so the caller may retry after a failed send. + * No-op when the record is already completed or absent. + */ + release(tenant: TenantContext, key: string): Promise; +} + +export function resolveIdempotencyTtlMs(env: NodeJS.ProcessEnv = process.env): number { + const raw = env.IDEMPOTENCY_TTL_MS; + if (raw === undefined || raw === '') return DEFAULT_IDEMPOTENCY_TTL_MS; + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_IDEMPOTENCY_TTL_MS; + return parsed; +} + +export function isExpired(record: IdempotencyRecord, nowMs: number = Date.now()): boolean { + const expiresAt = Date.parse(record.expiresAt); + if (!Number.isFinite(expiresAt)) return true; + return nowMs >= expiresAt; +} + +export function buildIdempotencyRecord( + tenant: TenantContext, + key: string, + status: IdempotencyStatus, + ttlMs: number, + response?: SendResponse, + nowMs: number = Date.now(), +): IdempotencyRecord { + return { + key, + tenantId: tenant.tenantId, + environment: tenant.environment, + status, + response, + createdAt: new Date(nowMs).toISOString(), + expiresAt: new Date(nowMs + ttlMs).toISOString(), + }; +} diff --git a/apps/api/src/idempotency/index.ts b/apps/api/src/idempotency/index.ts new file mode 100644 index 0000000..e19e6c1 --- /dev/null +++ b/apps/api/src/idempotency/index.ts @@ -0,0 +1,21 @@ +export { + DEFAULT_IDEMPOTENCY_TTL_MS, + buildIdempotencyRecord, + isExpired, + resolveIdempotencyTtlMs, + type IdempotencyBeginResult, + type IdempotencyRecord, + type IdempotencyStatus, + type IdempotencyStore, +} from './idempotency-store'; +export { + IDEMPOTENCY_KEY_HEADER, + validateIdempotencyKey, + type IdempotencyKeyValidation, +} from './idempotency-key'; +export { MemoryIdempotencyStore } from './memory-idempotency-store'; +export { + BlobIdempotencyStore, + IdempotencyStoreError, + type BlobIdempotencyStoreOptions, +} from './blob-idempotency-store'; diff --git a/apps/api/src/idempotency/memory-idempotency-store.spec.ts b/apps/api/src/idempotency/memory-idempotency-store.spec.ts new file mode 100644 index 0000000..3707f15 --- /dev/null +++ b/apps/api/src/idempotency/memory-idempotency-store.spec.ts @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import type { TenantContext } from '@singleton-sd/post-kit-types'; +import { MemoryIdempotencyStore } from './memory-idempotency-store'; + +const TENANT_A: TenantContext = { tenantId: 'inkads', environment: 'development' }; +const TENANT_B: TenantContext = { tenantId: 'other', environment: 'development' }; + +describe('MemoryIdempotencyStore', () => { + it('claims a new key, completes, and replays the stored response', async () => { + const store = new MemoryIdempotencyStore({ ttlMs: 60_000 }); + assert.deepEqual(await store.begin(TENANT_A, 'k1'), { outcome: 'claimed' }); + + const response = { id: 'corr-1', status: 'sent' as const }; + await store.complete(TENANT_A, 'k1', response); + + assert.deepEqual(await store.begin(TENANT_A, 'k1'), { + outcome: 'replay', + response, + }); + }); + + it('returns in_progress for a concurrent claim', async () => { + const store = new MemoryIdempotencyStore({ ttlMs: 60_000 }); + assert.deepEqual(await store.begin(TENANT_A, 'k1'), { outcome: 'claimed' }); + assert.deepEqual(await store.begin(TENANT_A, 'k1'), { outcome: 'in_progress' }); + }); + + it('isolates the same key across tenants', async () => { + const store = new MemoryIdempotencyStore({ ttlMs: 60_000 }); + assert.deepEqual(await store.begin(TENANT_A, 'shared'), { outcome: 'claimed' }); + assert.deepEqual(await store.begin(TENANT_B, 'shared'), { outcome: 'claimed' }); + }); + + it('releases an in-progress claim so a retry can claim again', async () => { + const store = new MemoryIdempotencyStore({ ttlMs: 60_000 }); + await store.begin(TENANT_A, 'k1'); + await store.release(TENANT_A, 'k1'); + assert.deepEqual(await store.begin(TENANT_A, 'k1'), { outcome: 'claimed' }); + }); + + it('treats expired records as absent', async () => { + const store = new MemoryIdempotencyStore({ ttlMs: 1 }); + await store.begin(TENANT_A, 'k1'); + await new Promise((r) => setTimeout(r, 5)); + assert.deepEqual(await store.begin(TENANT_A, 'k1'), { outcome: 'claimed' }); + }); +}); diff --git a/apps/api/src/idempotency/memory-idempotency-store.ts b/apps/api/src/idempotency/memory-idempotency-store.ts new file mode 100644 index 0000000..e6da811 --- /dev/null +++ b/apps/api/src/idempotency/memory-idempotency-store.ts @@ -0,0 +1,55 @@ +import type { SendResponse, TenantContext } from '@singleton-sd/post-kit-types'; +import { + buildIdempotencyRecord, + isExpired, + resolveIdempotencyTtlMs, + type IdempotencyBeginResult, + type IdempotencyRecord, + type IdempotencyStore, +} from './idempotency-store'; + +function storageKey(tenant: TenantContext, key: string): string { + return `${tenant.tenantId}:${tenant.environment}:${key}`; +} + +/** + * In-memory IdempotencyStore for unit tests. + * Not safe across Function instances — production uses BlobIdempotencyStore. + */ +export class MemoryIdempotencyStore implements IdempotencyStore { + private readonly records = new Map(); + private readonly ttlMs: number; + + constructor(options?: { ttlMs?: number }) { + this.ttlMs = options?.ttlMs ?? resolveIdempotencyTtlMs(); + } + + async begin(tenant: TenantContext, key: string): Promise { + const mapKey = storageKey(tenant, key); + const existing = this.records.get(mapKey); + if (existing && !isExpired(existing)) { + if (existing.status === 'completed' && existing.response) { + return { outcome: 'replay', response: existing.response }; + } + return { outcome: 'in_progress' }; + } + + this.records.set(mapKey, buildIdempotencyRecord(tenant, key, 'in_progress', this.ttlMs)); + return { outcome: 'claimed' }; + } + + async complete(tenant: TenantContext, key: string, response: SendResponse): Promise { + const mapKey = storageKey(tenant, key); + this.records.set( + mapKey, + buildIdempotencyRecord(tenant, key, 'completed', this.ttlMs, response), + ); + } + + async release(tenant: TenantContext, key: string): Promise { + const mapKey = storageKey(tenant, key); + const existing = this.records.get(mapKey); + if (!existing || existing.status === 'completed') return; + this.records.delete(mapKey); + } +} diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 7c9dde2..6faea46 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -110,6 +110,7 @@ repositories and published by CI. | API | Azure Function App `ssd-postkit-api-prod-ae` | Plan `ssd-postkit-plan-prod-ae` (Y1 Consumption) | | Storage | `ssdpostkitstprodae` | Function App storage | | Template storage | `TEMPLATE_STORAGE_ACCOUNT` / `TEMPLATE_STORAGE_CONTAINER` (default `templates`) | Read with `DefaultAzureCredential` | +| Send idempotency | `IDEMPOTENCY_STORAGE_ACCOUNT` (falls back to template account) / container `idempotency` | Blob ledger; see [`send-idempotency.md`](./send-idempotency.md) | | App configuration | `ssd-postkit-appcs-prod-ae` | Free SKU; non-secret settings + Key Vault references | | Secrets | Key Vault `ssd-global-kv-prod-ae` | Resource group `rg-ssd-global`; IDs in [`SETUP.md`](../../SETUP.md) | | Packages | npmjs public `@singleton-sd/post-kit-*` | Not published yet — see **Not yet implemented** | diff --git a/docs/architecture/request-lifecycle.md b/docs/architecture/request-lifecycle.md index 90b8cc8..87915b3 100644 --- a/docs/architecture/request-lifecycle.md +++ b/docs/architecture/request-lifecycle.md @@ -7,7 +7,8 @@ supporting behaviour lives in `apps/api/src/tenant/`, `apps/api/src/telemetry/`. For the wider system picture see [`overview.md`](./overview.md); for the -tenant boundary see [`multi-tenant-security.md`](./multi-tenant-security.md). +tenant boundary see [`multi-tenant-security.md`](./multi-tenant-security.md); +for send idempotency see [`send-idempotency.md`](./send-idempotency.md). ## Route @@ -66,12 +67,20 @@ credential. html = Handlebars.compile(templateHtml)(variables) HTML-escaping stays on (noEscape: false) | -9. from address - EMAIL_FROM_ADDRESS required; EMAIL_FROM_NAME optional - missing -> 503 PROVIDER_FAILURE +9. from address / tenant sender config + resolveTenantEmailConfig(tenant) + missing -> 503 TENANT_CONFIG_NOT_FOUND / PROVIDER_FAILURE + | +9b. Idempotency-Key (optional) + absent -> skip (at-least-once) + invalid -> 400 before storage + begin claim in Blob ledger (see send-idempotency.md) + completed replay -> 200 original SendResponse (no provider call) + in flight -> 409 IDEMPOTENCY_IN_PROGRESS | 10. provider.send({ to, from, fromName, subject, html, correlationId }) createEmailProvider(process.env) -> development sink or Forward Email + on success with claim -> complete ledger; on failure -> release claim failure -> 502/503 PROVIDER_FAILURE | 11. log send.request.completed (outcome, durationMs, tenantId, templateKey, @@ -117,6 +126,8 @@ Consumers should branch on `code`, not on the message text or the status. | `EMAIL_FROM_ADDRESS` is not configured | `503` | `PROVIDER_FAILURE` | Service-side misconfiguration. Retry later; report the correlation ID. | | Provider failed with kind `configuration`, `transient`, or `rate_limit` | `503` | `PROVIDER_FAILURE` | Retry with backoff. | | Provider failed with any other kind (`validation`, `permanent`, `cancelled`) | `502` | `PROVIDER_FAILURE` | Do not blindly retry — the message was rejected downstream. | +| Same `Idempotency-Key` still in flight for this tenant | `409` | `IDEMPOTENCY_IN_PROGRESS` | Wait and retry the same key; do not start a parallel send. | +| `Idempotency-Key` present but empty, oversized, or bad charset | `400` | `INVALID_RECIPIENT` | Fix the header: 1–128 of `[A-Za-z0-9._:~-]`. (Code reused for request-input validation.) | | Unhandled exception | `500` | `PROVIDER_FAILURE` | Retry with backoff; report the correlation ID. | Two things to be aware of when reading the table: diff --git a/docs/architecture/send-idempotency.md b/docs/architecture/send-idempotency.md new file mode 100644 index 0000000..18620b4 --- /dev/null +++ b/docs/architecture/send-idempotency.md @@ -0,0 +1,90 @@ +# Send idempotency + +How `POST /emails/send` avoids double-sends when a consumer retries with the +same `Idempotency-Key`. + +See also [`request-lifecycle.md`](./request-lifecycle.md). + +## Contract + +| Header | Required | Behaviour | +| --- | --- | --- | +| `Idempotency-Key` | No | When **absent**, behaviour is unchanged (at-least-once). When **present**, the key is validated, then used as a per-tenant claim before the provider is called. | + +- Scope: `{tenantId, environment}` + key. The same key from two tenants (or two + environments of the same tenant id) are independent. +- Replay of a **completed** request returns the original `SendResponse` and does + **not** call the email provider again. +- Replay while the first request is still **in flight** returns HTTP `409` with + `PostKitErrorCode.IDEMPOTENCY_IN_PROGRESS`. +- Unsafe or oversized keys are rejected with HTTP `400` **before** any storage + access (`1–128` characters of `[A-Za-z0-9._:~-]`). + +This issue does **not** implement automatic retries; it only makes client +retries safe. Automatic retry policy is a separate concern. + +## Persistence choice: Azure Blob (not Table) + +| Option | Pros | Cons | +| --- | --- | --- | +| **Azure Blob** (chosen) | Already used by the API (`@azure/storage-blob`, `DefaultAzureCredential`); conditional create via `If-None-Match: *`; no new dependency / lockfile churn; JSON payload fits the small record shape | No native TTL — expiry is checked on read; optional lifecycle rules reclaim bytes | +| Azure Table | Cheap entity store; partition+row key maps cleanly to tenant+key | New `@azure/data-tables` dependency; another client/credential surface for the same storage account | + +**Decision:** store one JSON blob per claim in a dedicated container (default +`idempotency`) on the same storage account as templates +(`IDEMPOTENCY_STORAGE_ACCOUNT` falls back to `TEMPLATE_STORAGE_ACCOUNT`). + +Blob path: + +```text +tenants/{tenantId}/{environment}/idempotency/{sha256(key)}.json +``` + +The consumer key is hashed so the blob name stays path-safe; the original key +is still stored inside the JSON for debugging (it is not a secret, but never +log it next to recipient data). + +### Record shape + +Stored fields only: + +| Field | Purpose | +| --- | --- | +| `key` | Validated consumer key | +| `tenantId` / `environment` | Tenant scope | +| `status` | `in_progress` \| `completed` | +| `response` | Original `SendResponse` when completed | +| `createdAt` / `expiresAt` | Timestamps + TTL | + +**Never stored:** recipients, variables, rendered HTML/subject, tokens. + +### TTL / expiry + +Default TTL: **24 hours** (`IDEMPOTENCY_TTL_MS`, default `86400000`). + +On `begin`, expired records are treated as absent and may be overwritten. Blob +lifecycle management on the `idempotency` container can delete old objects for +cost control; application correctness does not depend on that delete happening +immediately. + +### Claim flow + +```text +1. Validate Idempotency-Key (reject → 400) +2. Conditional create blob status=in_progress (If-None-Match: *) + - created → claim held; continue to provider.send + - conflict → read existing + - completed + fresh → return stored SendResponse (200) + - in_progress + fresh → 409 IDEMPOTENCY_IN_PROGRESS + - expired → overwrite and claim +3. On provider success → overwrite blob status=completed + response +4. On provider failure → delete in-progress blob (release) so the same key may retry +``` + +## Configuration + +| Env | Default | Notes | +| --- | --- | --- | +| `IDEMPOTENCY_STORAGE_ACCOUNT` | `TEMPLATE_STORAGE_ACCOUNT` | Storage account name | +| `IDEMPOTENCY_STORAGE_CONTAINER` | `idempotency` | Dedicated container | +| `IDEMPOTENCY_TTL_MS` | `86400000` (24h) | Soft expiry checked on read | diff --git a/docs/operations/troubleshooting.md b/docs/operations/troubleshooting.md index e099cb8..34331b9 100644 --- a/docs/operations/troubleshooting.md +++ b/docs/operations/troubleshooting.md @@ -54,6 +54,8 @@ ones. | 502 | `PROVIDER_FAILURE` | Rare; `kind=cancelled` | The send was aborted (caller/host cancellation) | Host shutdown, scaling events, or client disconnects in the same window | Yes — the message was probably never sent, but confirm no duplicate first | | 503 | `STORAGE_FAILURE` | Fails immediately, before auth; no `tenantId` or `templateKey`; `app configuration load failed` is logged | Azure App Configuration load threw — endpoint unreachable, identity lacks access, or a Key Vault reference is invalid or has no value | `AZURE_APPCONFIGURATION_ENDPOINT`, the Function App managed identity's App Configuration and Key Vault role assignments | Yes — the loader clears its cache on failure and retries on the next request | | 500 | `PROVIDER_FAILURE` | Unexpected failure; `send failed` is logged with only the error `name` | Any unhandled exception — e.g. a Blob Storage error that is not a not-found (auth, throttling, network), or a Handlebars compilation failure | The `send failed` entry's error `name` for that correlation ID, plus Function App exception telemetry | Yes once, but escalate if it repeats | +| 409 | `IDEMPOTENCY_IN_PROGRESS` | Same `Idempotency-Key` replayed while the first send is still running; no second provider call | A concurrent or overlapping retry for the same tenant + key before the first request completes (or before a failed claim is released) | Whether another in-flight send shares the key; wait for the first response or for the claim TTL (default 24h) documented in [`send-idempotency.md`](../architecture/send-idempotency.md) | Yes — after a short wait, retry the **same** key; do not mint a new key for the same logical send | +| 400 | `INVALID_RECIPIENT` | Rejected before storage; message mentions `Idempotency-Key`; `failureCategory=invalid_idempotency_key` | Header present but empty, longer than 128 characters, or outside `[A-Za-z0-9._:~-]` | The caller's `Idempotency-Key` value (not a secret, but still avoid pasting production keys into tickets unnecessarily) | No — fix the header | Notes on reading this table: @@ -63,10 +65,9 @@ Notes on reading this table: `kind`. - `STORAGE_FAILURE` is currently returned **only** for App Configuration load failure. Blob Storage failures other than "not found" surface as `500`. -- All nine `PostKitErrorCode` values are reachable from this endpoint and all - nine appear above: `UNAUTHENTICATED`, `UNAUTHORIZED`, `INVALID_TEMPLATE`, - `INVALID_RECIPIENT`, `MISSING_VARIABLES`, `TEMPLATE_NOT_FOUND`, - `TENANT_CONFIG_NOT_FOUND`, `PROVIDER_FAILURE`, `STORAGE_FAILURE`. +- `PostKitErrorCode` values reachable from this endpoint appear above, including + `IDEMPOTENCY_IN_PROGRESS` (409) and invalid `Idempotency-Key` (400, + `INVALID_RECIPIENT` with `failureCategory=invalid_idempotency_key`). ## Correlation IDs and log fields diff --git a/packages/post-kit-types/README.md b/packages/post-kit-types/README.md index 8a1d6ff..6405ce9 100644 --- a/packages/post-kit-types/README.md +++ b/packages/post-kit-types/README.md @@ -54,6 +54,7 @@ switch (error.code) { case PostKitErrorCode.INVALID_RECIPIENT: // 422 — recipient address invalid case PostKitErrorCode.PROVIDER_FAILURE: // 502 — email provider error case PostKitErrorCode.STORAGE_FAILURE: // 502 — blob storage error + case PostKitErrorCode.IDEMPOTENCY_IN_PROGRESS: // 409 — same key still in flight } ``` diff --git a/packages/post-kit-types/src/index.spec.ts b/packages/post-kit-types/src/index.spec.ts index 45c9ea1..2d4ee67 100644 --- a/packages/post-kit-types/src/index.spec.ts +++ b/packages/post-kit-types/src/index.spec.ts @@ -177,13 +177,14 @@ describe('PostKitErrorCode', () => { assert.equal(PostKitErrorCode.PROVIDER_FAILURE, 'PROVIDER_FAILURE'); assert.equal(PostKitErrorCode.STORAGE_FAILURE, 'STORAGE_FAILURE'); assert.equal(PostKitErrorCode.TENANT_CONFIG_NOT_FOUND, 'TENANT_CONFIG_NOT_FOUND'); + assert.equal(PostKitErrorCode.IDEMPOTENCY_IN_PROGRESS, 'IDEMPOTENCY_IN_PROGRESS'); }); - it('has exactly 11 codes', () => { + it('has exactly 12 codes', () => { const codes = Object.keys(PostKitErrorCode).filter( (k) => typeof PostKitErrorCode[k as keyof typeof PostKitErrorCode] === 'string', ); - assert.equal(codes.length, 11); + assert.equal(codes.length, 12); }); }); diff --git a/packages/post-kit-types/src/send.ts b/packages/post-kit-types/src/send.ts index 969387a..08c9567 100644 --- a/packages/post-kit-types/src/send.ts +++ b/packages/post-kit-types/src/send.ts @@ -36,6 +36,11 @@ export enum PostKitErrorCode { STORAGE_FAILURE = 'STORAGE_FAILURE', /** The authenticated tenant has no email sender configuration for this environment. */ TENANT_CONFIG_NOT_FOUND = 'TENANT_CONFIG_NOT_FOUND', + /** + * The same Idempotency-Key is already being processed for this tenant. + * Wait and retry; do not treat as a permanent failure. + */ + IDEMPOTENCY_IN_PROGRESS = 'IDEMPOTENCY_IN_PROGRESS', } /**