Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
Expand Down
62 changes: 59 additions & 3 deletions tests/contracts.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'
);
});
});
Expand Down Expand Up @@ -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', '<html>Bad Gateway</html>'), 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;
}
);
});
});
Loading