diff --git a/CHANGELOG.md b/CHANGELOG.md index b0a28d1..437195d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Added + +- `KeyMintApiError.errorCode`: the stable string error code the API now sends with every error (for example `license_invalid_or_forbidden`, `rate_limited`). Branch on it instead of `message`. Absent when the response has none; existing fields are unchanged. + ### Security - Lockfile: `follow-redirects` 1.16.0 -> 1.16.1 (GHSA-q3jh-wrc6-jgq8, GHSA-8r9p-f939-6h3c), plus dev-only `brace-expansion` and `js-yaml` fixes. No `axios` release requires a fixed `follow-redirects` yet, so existing installs should run `npm update follow-redirects`. diff --git a/README.md b/README.md index 2ed9c6f..7a7da7e 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,37 @@ Error messages never contain the license key. Invalid options (no public keys, a **Key rotation.** `publicKeys` accepts several PEMs, so you can ship the old and new keys side by side during a rotation. +## Error Handling + +SDK methods reject with a `KeyMintApiError` object: `{ message, code, errorCode?, status? }`. Branch on `errorCode`, a stable string such as `license_invalid_or_forbidden` or `rate_limited`, not on `message`, which is written for people and may be reworded. See the [error code reference](https://keymint.dev/docs/api-reference/error-codes) for the full list. + +```typescript +import { KeyMint, KeyMintApiError } from 'keymint'; + +try { + const res = await sdk.activateKey({ productId, licenseKey, hostId }); + console.log('Activated:', res.message); +} catch (err) { + const error = err as KeyMintApiError; + switch (error.errorCode) { + case 'license_invalid_or_forbidden': + // Rejected: ask the user to re-enter the key or contact support. + break; + case 'activation_limit_reached': + // Ask the user to deactivate another device. + break; + case 'rate_limited': + case 'service_unavailable': + // No answer yet: keep the last known state and retry later. + break; + default: + console.error(`Keymint error ${error.errorCode ?? error.code} (HTTP ${error.status}): ${error.message}`); + } +} +``` + +`errorCode` is absent when the response has none, for example a non-JSON body from a proxy or a network failure, where `code` is `-1`. The numeric `code` is unchanged. + ## Idempotency All mutating SDK calls (such as creating keys, activations, customer management, etc.) support idempotency keys to safely retry requests in case of network drops. Pass a unique key (recommended: UUID v4) as the optional request options: diff --git a/src/index.ts b/src/index.ts index fc345a2..35c311b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -544,6 +544,7 @@ export class KeyMint { || nested?.message || defaultApiErrorMessage, code: 'code' in responseData && typeof responseData.code === 'number' ? responseData.code : -1, + ...('errorCode' in responseData && typeof responseData.errorCode === 'string' ? { errorCode: responseData.errorCode } : {}), status: axiosError.response.status }; } else { diff --git a/src/types.ts b/src/types.ts index 8285b35..f7fc587 100644 --- a/src/types.ts +++ b/src/types.ts @@ -55,6 +55,7 @@ export interface CreateKeyResponse { export interface KeyMintApiError { message: string; // Descriptive error message code: number; // API specific error code + errorCode?: string; // Stable string error code (e.g. "license_not_found"); branch on this, not on message status?: number; // HTTP status code, optional error?: { code?: string; message?: string; details?: unknown }; } diff --git a/tests/contracts.test.cjs b/tests/contracts.test.cjs index feec43a..0654772 100644 --- a/tests/contracts.test.cjs +++ b/tests/contracts.test.cjs @@ -49,13 +49,14 @@ test('nested API errors preserve the actionable server message', async () => { response.setHeader('content-type', 'application/json'); response.end(JSON.stringify({ success: false, - error: { code: 'CUSTOMER_EMAIL_EXISTS', message: 'Customer email already exists in team' }, - code: 1 + error: { code: 'CUSTOMER_EMAIL_EXISTS', message: 'Customer email already exists in workspace' }, + code: 2, + errorCode: 'conflict' })); }, async baseUrl => { await assert.rejects( () => new KeyMint('admin_test', baseUrl).createKey({ productId: 'product_123' }), - error => error.message === 'Customer email already exists in team' && error.code === 1 + error => error.message === 'Customer email already exists in workspace' && error.code === 2 && error.errorCode === 'conflict' ); }); }); @@ -161,3 +162,58 @@ test('deleteKey rejects an unknown key with the SDK error shape', async () => { ); }); }); + +function errorResponder(status, contentType, body) { + return (request, response) => { + request.resume(); + response.statusCode = status; + response.setHeader('content-type', contentType); + response.end(body); + }; +} + +test('API errors expose errorCode when the response has one', async () => { + await withServer(errorResponder(401, 'application/json', JSON.stringify({ + message: 'Invalid license key or insufficient permissions', code: 1, errorCode: 'license_invalid_or_forbidden' + })), async baseUrl => { + await assert.rejects( + () => new KeyMint('client_test', baseUrl).activateKey({ productId: 'product_123', licenseKey: 'license_123' }), + error => { + assert.deepEqual(error, { + message: 'Invalid license key or insufficient permissions', + code: 1, + errorCode: 'license_invalid_or_forbidden', + status: 401 + }); + return true; + } + ); + }); +}); + +test('API errors without errorCode keep the previous shape', async () => { + await withServer(errorResponder(429, 'application/json', JSON.stringify({ + message: 'Too many requests', code: 3 + })), async baseUrl => { + await assert.rejects( + () => new KeyMint('client_test', baseUrl).activateKey({ productId: 'product_123', licenseKey: 'license_123' }), + error => { + assert.deepEqual(error, { message: 'Too many requests', code: 3, status: 429 }); + assert.equal('errorCode' in error, false); + return true; + } + ); + }); +}); + +test('non-JSON API errors have no errorCode', async () => { + await withServer(errorResponder(502, 'text/html', 'Bad Gateway'), async baseUrl => { + await assert.rejects( + () => new KeyMint('client_test', baseUrl).activateKey({ productId: 'product_123', licenseKey: 'license_123' }), + error => { + assert.deepEqual(error, { message: 'An API error occurred during the request.', code: -1, status: 502 }); + return true; + } + ); + }); +});