Error Handling
Comprehensive guide to handling errors when integrating with the License Server API, including retry strategies, common error scenarios, and debugging techniques.
HTTP Status Codes
The License Server API uses standard HTTP status codes to indicate the success or failure of requests:
Success Codes
- 200 OK - Request successful
Client Error Codes
- 400 Bad Request - Invalid request parameters
- 403 Forbidden - Forbidden by a business rule: missing/invalid admin key, but also license-specific states like expired, revoked/inactive, or activation-limit-exceeded on otherwise-public endpoints
- 404 Not Found - License key or endpoint not found
- 409 Conflict - The license is already in the requested state (e.g. revoking an already-revoked license)
- 429 Too Many Requests - Rate limit exceeded
Server Error Codes
- 500 Internal Server Error - Server-side error
- 503 Service Unavailable - A concurrent-update conflict on
POST /v1/track-usageorPOST /v1/admin/reset-usagespecifically — both use optimistic locking on the license's usage counters, and lose that race under concurrent writes to the same key. Retrying the request is the correct response, per each route's own{"error": "Failed to track usage due to a concurrent update - please retry"}-shaped message.
The API never returns 201 Created — every successful write (issuing, revoking, activating, etc.) responds 200.
Error Response Format
Error responses are a plain {"error": "..."} object — there's no code or details field anywhere in the API:
{
"error": "Descriptive error message"
}A few specific endpoints add extra sibling fields alongside error for that one case (e.g. verify-license adds "valid": false). A 429 comes from the rate limiter itself and has its own shape — statusCode, error and message, with no retryAfter field; see Rate Limiting below. There is no generic code/details envelope used API-wide. track-usage and validate-license don't add a valid field on error — track-usage's error responses are just {"error": "..."}, and validate-license always returns 200 {"valid": false} with no error field at all.
Refused, or No Answer
Your API address
The examples use https://your-license-api.com as a stand-in for your License Server's API address. Put yours in its place:
- Hosted account:
https://license-cloud-api.casazium.com. The console's Settings → API access → API base URL shows it with/v1already on the end,https://license-cloud-api.casazium.com/v1, so that value replaceshttps://your-license-api.com/v1as a whole: don't add a second/v1. Notlicense-cloud.casazium.com: that's the console, and an API call sent there gets a JSON 404 naming the right address. Where an example sends$ADMIN_API_KEY, use your account's API key from the same Settings → API access section instead: a hosted account doesn't acceptADMIN_API_KEY. - Self-hosted: your own server's address. The self-hosted bundle answers on
http://localhost:3001on the machine running it, sohttp://localhost:3001/v1replaceshttps://your-license-api.com/v1.
Every license check ends in one of three states, and your app should treat them differently:
| What came back | State | What to do |
|---|---|---|
200 with valid: true and the product_uuid you expect | Licensed | Run, and remember this as the last known good state |
4xx other than 429 (unknown key, revoked, expired, no seats left), or a 200 for a different product | Refused | Believe it: stop, tell the user why, and drop any cached result or offline license file |
429, 5xx, a network error, or a timeout | No answer | Not a verdict on the license. Keep the last known state (or an offline license file), and check again later. Never report the license as invalid for this |
Checking product_uuid matters: a key issued for any other product (on the hosted tier, any other account's product) also gets valid: true. Get your product's product_uuid from the issue-license response once and hard-code it — see Issuing Licenses.
The LicenseClient below turns a verify-license call into one of those three states. Later examples on this page build on it:
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
class LicenseClient {
// baseUrl includes /v1, e.g. "https://your-license-api.com/v1".
// productUuid is your product's product_uuid from the issue-license response.
constructor(baseUrl, productUuid, { timeoutMs = 10000, maxRetries = 2, retryDelayMs = 500 } = {}) {
this.baseUrl = baseUrl;
this.productUuid = productUuid;
this.timeoutMs = timeoutMs;
this.maxRetries = maxRetries;
this.retryDelayMs = retryDelayMs;
}
// Resolves to { state: 'licensed', license }, { state: 'refused', status, reason }
// or { state: 'no-answer', status?, reason, retryAfterSeconds? }. Never throws.
async verifyLicense(key) {
for (let attempt = 0; ; attempt++) {
let response;
try {
response = await fetch(`${this.baseUrl}/verify-license`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key }),
signal: AbortSignal.timeout(this.timeoutMs)
});
} catch (error) {
// A network error, or the timeout fired (error.name === 'TimeoutError').
if (attempt < this.maxRetries) {
await sleep(this.retryDelayMs * 2 ** attempt);
continue;
}
return { state: 'no-answer', reason: error.message };
}
if (response.status === 429) {
// Retry-After is the number of seconds until the rate-limit window
// resets, which can be up to 15 minutes. Don't wait that out here.
return {
state: 'no-answer',
status: 429,
reason: 'Rate limited',
retryAfterSeconds: Number(response.headers.get('Retry-After')) || null
};
}
if (response.status >= 500) {
if (attempt < this.maxRetries) {
await sleep(this.retryDelayMs * 2 ** attempt);
continue;
}
return { state: 'no-answer', status: response.status, reason: `HTTP ${response.status}` };
}
const data = await response.json().catch(() => ({}));
if (!response.ok) {
// 404 unknown key, 403 revoked or expired: the server said no.
return { state: 'refused', status: response.status, reason: data.error || `HTTP ${response.status}` };
}
if (data.product_uuid !== this.productUuid) {
return { state: 'refused', status: response.status, reason: 'License belongs to a different product' };
}
return { state: 'licensed', license: data };
}
}
}With @casazium/license-sdk 0.4.0 or later, the same check is shorter. verifyKey() throws on any non-2xx response, with the HTTP status on err.status; a network error has no status, and a license for another product throws with no status and a message containing product_uuid mismatch. The SDK retries network errors and 5xx a few times itself, doesn't retry a 429, and has no timeout of its own, so pass it a fetch that adds one:
import { CasaziumLicenseClient } from '@casazium/license-sdk';
const PRODUCT_UUID = 'your-product-uuid';
const client = new CasaziumLicenseClient({
baseUrl: 'https://your-license-api.com/v1',
fetch: (url, options) => fetch(url, { ...options, signal: AbortSignal.timeout(10000) })
});
// true when the server answered no: a 4xx other than 429, or the SDK's own
// refusal of a key for another product. Not a 404 for a route the server
// doesn't have.
function isRefusal(error) {
if (error.status === undefined) {
return error.message.includes('product_uuid mismatch');
}
// A 404 ending ": Not Found" is a route the server doesn't have (a server
// older than the SDK needs, or a base URL missing /v1), not a refused key.
if (error.status === 404 && /: Not Found$/.test(error.message)) return false;
return error.status >= 400 && error.status < 500 && error.status !== 429;
}
async function checkLicense(key) {
try {
const license = await client.verifyKey(key, PRODUCT_UUID);
return { state: 'licensed', license };
} catch (error) {
return {
state: isRefusal(error) ? 'refused' : 'no-answer',
status: error.status,
reason: error.message
};
}
}Common Error Scenarios
1. License Key Not Found
Scenario: License verification is called with a key that doesn't exist. There's no separate "invalid format" check — any string that isn't a real key gets the same 404, whether it's malformed or just wrong:
curl -X POST https://your-license-api.com/v1/verify-license \
-H "Content-Type: application/json" \
-d '{"key": "invalid-key-format"}'Response (404):
{
"error": "License key not found",
"valid": false
}Handling: this is a refusal. With the LicenseClient above:
const licenseClient = new LicenseClient('https://your-license-api.com/v1', 'your-product-uuid');
const result = await licenseClient.verifyLicense(key);
if (result.state === 'refused' && result.status === 404) {
showError('License key not found. Please check your license key.');
}2. Expired License
Scenario: License exists but has expired.
Response (403):
{
"error": "License has expired",
"valid": false
}There's no expired boolean or expires_at field in the response — you have to branch on the error message text. Note also that a revoked (or otherwise inactive) license returns a different message from verify-license, "License is not active", not anything containing the word "revoked" (activate-license says "License has been revoked"):
Handling (result is what licenseClient.verifyLicense() returns):
function handleLicenseVerification(result) {
if (result.state === 'licensed') {
return true;
}
if (result.state === 'no-answer') {
// Not a verdict on the license: keep the last known state (see
// Graceful Degradation below) rather than telling the user the key is bad.
showWarning('Could not reach the license server. Will try again later.');
return null;
}
// Refused
if (result.reason.includes('expired')) {
showError('Your license has expired. Please renew your license to continue.');
} else if (result.reason.includes('not active')) {
showError('Your license is not active. Please contact support.');
} else if (result.status === 404) {
showError('Invalid license key. Please check your key and try again.');
} else {
showError(`This license can't be used here: ${result.reason}`);
}
return false;
}3. Activation Limit Reached
Scenario: Trying to activate a license that has reached its maximum activations.
Response (403):
{
"error": "Activation limit exceeded for this license key"
}Handling (activate-license doesn't check which product a key belongs to, so verify the key with the LicenseClient above first):
async function activateLicense(key, instanceId) {
let response;
try {
response = await fetch('https://your-license-api.com/v1/activate-license', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key, instance_id: instanceId }),
signal: AbortSignal.timeout(10000)
});
} catch (error) {
return { state: 'no-answer', error: 'Could not reach the license server. Please try again.' };
}
if (response.status === 429 || response.status >= 500) {
return { state: 'no-answer', error: 'The license server is busy. Please try again later.' };
}
const data = await response.json().catch(() => ({}));
if (response.ok) {
// `token` is only present when this call created the activation; a
// repeat activation of the same instance returns alreadyActivated: true.
return { state: 'activated', token: data.token, alreadyActivated: data.alreadyActivated === true };
}
if (data.error === 'Activation limit exceeded for this license key') {
return {
state: 'refused',
error: 'License activation limit reached. Please deactivate unused instances or upgrade your license.'
};
}
// Not found, revoked or expired
return { state: 'refused', error: data.error || `HTTP ${response.status}` };
}4. Rate Limiting
Scenario: Too many requests in a short time period.
Response (429):
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 20
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 897
Retry-After: 897
{
"statusCode": 429,
"error": "Too Many Requests",
"message": "Rate limit exceeded, retry in 15 minutes"
}The body has no retryAfter field, and the message rounds to minutes. Read the Retry-After header instead: it's the number of seconds until the window resets (the same value as X-RateLimit-Reset), and since every window is 15 minutes it can be as long as 900.
Handling: don't sleep for Retry-After inside a license check — that can block your app for up to 15 minutes per attempt. A 429 is "no answer": keep running on the last known state, and check again once the window has reset. The LicenseClient above returns retryAfterSeconds for this:
const result = await licenseClient.verifyLicense(key);
if (result.state === 'no-answer' && result.status === 429) {
// Keep the last known state; check again after the window resets.
setTimeout(() => recheckLicense(key), (result.retryAfterSeconds ?? 60) * 1000);
}5. Admin Authentication Errors
Scenario: Invalid or missing admin API key for protected endpoints.
Response (403):
{
"error": "Unauthorized"
}A 403 from an admin endpoint isn't always an authentication failure: business rules use 403 too — for example "License has expired" when un-revoking an expired license, or a quota error on the hosted tier. Check the error text, not just the status.
Handling:
class AdminClient {
constructor(baseUrl, adminKey) {
this.baseUrl = baseUrl;
this.adminKey = adminKey;
}
async makeAdminRequest(endpoint, options = {}) {
// Spread options first, then build headers, so a caller's own headers
// are added to Content-Type and Authorization instead of replacing them.
const response = await fetch(`${this.baseUrl}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
...options.headers,
'Authorization': `Bearer ${this.adminKey}`
}
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
if (response.status === 403 && body.error === 'Unauthorized') {
console.error('Invalid admin API key. Please verify your credentials.');
throw new Error('Admin authentication failed. Please check your API key.');
}
const error = new Error(body.error || `HTTP ${response.status}`);
error.status = response.status;
throw error;
}
return response.json();
}
}Comprehensive Error Handling Strategy
1. Circuit Breaker Pattern
Stop calling the server for a while after repeated failures. Only "no answer" counts as a failure here — a refusal is a real answer. While the circuit is open, the result is still "no answer", never valid: false:
class CircuitBreaker {
constructor(threshold = 5, timeout = 60000) {
this.threshold = threshold;
this.timeout = timeout;
this.failureCount = 0;
this.lastFailureTime = null;
this.state = 'CLOSED'; // CLOSED, OPEN, HALF_OPEN
}
async execute(operation) {
if (this.state === 'OPEN') {
if (Date.now() - this.lastFailureTime > this.timeout) {
this.state = 'HALF_OPEN';
} else {
throw new Error('Circuit breaker is OPEN');
}
}
try {
const result = await operation();
this.state = 'CLOSED';
this.failureCount = 0;
return result;
} catch (error) {
this.failureCount++;
this.lastFailureTime = Date.now();
// One failed trial call while half-open reopens the circuit.
if (this.state === 'HALF_OPEN' || this.failureCount >= this.threshold) {
this.state = 'OPEN';
}
throw error;
}
}
}
// Usage
const circuitBreaker = new CircuitBreaker(3, 30000);
const licenseClient = new LicenseClient('https://your-license-api.com/v1', 'your-product-uuid');
async function verifyLicenseWithCircuitBreaker(key) {
try {
return await circuitBreaker.execute(async () => {
const result = await licenseClient.verifyLicense(key);
if (result.state === 'no-answer') {
throw new Error(result.reason);
}
return result; // licensed or refused
});
} catch (error) {
// No answer, or the circuit is open: keep the last known state.
return { state: 'no-answer', reason: error.message };
}
}2. Timeout Handling
Give every request a timeout with AbortSignal.timeout(), which also cancels the request itself (racing the fetch against a timer would leave it running). A timeout is "no answer", not a refusal. The LicenseClient above already does this; on its own it looks like:
async function verifyLicenseWithTimeout(key, timeoutMs = 15000) {
try {
const response = await fetch('https://your-license-api.com/v1/verify-license', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key }),
signal: AbortSignal.timeout(timeoutMs)
});
return response; // classify it as in LicenseClient above
} catch (error) {
if (error.name === 'TimeoutError') {
return null; // no answer
}
throw error;
}
}3. Comprehensive Error Logger
class ErrorLogger {
constructor(monitoringUrl = null) {
this.logs = [];
this.monitoringUrl = monitoringUrl; // e.g. your own error-tracking endpoint
}
logError(operation, error, context = {}) {
// typeof guards, because these globals exist in browsers but not in
// Node (window, and navigator before Node 21), or the other way round
// (process).
const logEntry = {
timestamp: new Date().toISOString(),
operation,
error: {
message: error.message,
stack: error.stack,
name: error.name
},
context,
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : undefined,
url: typeof window !== 'undefined' ? window.location.href : undefined
};
this.logs.push(logEntry);
const env = typeof process !== 'undefined' ? process.env.NODE_ENV : undefined;
// Log to console in development
if (env === 'development') {
console.error('License Error:', logEntry);
}
// Send to monitoring service in production
if (env === 'production' && this.monitoringUrl) {
this.sendToMonitoring(logEntry);
}
}
async sendToMonitoring(logEntry) {
try {
await fetch(this.monitoringUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(logEntry)
});
} catch (err) {
console.error('Failed to send error to monitoring:', err);
}
}
getRecentErrors(count = 10) {
return this.logs.slice(-count);
}
}
const errorLogger = new ErrorLogger();
// Usage in license operations
async function verifyLicenseWithLogging(key) {
const result = await licenseClient.verifyLicense(key);
if (result.state !== 'licensed') {
errorLogger.logError('license_verification', new Error(result.reason), {
state: result.state,
status: result.status,
key: key.substring(0, 8) + '...'
});
}
return result;
}Error Recovery Strategies
1. Graceful Degradation
Remember the last good result, and use it only when there's no answer. A refusal clears it, so a revoked or expired license stops working at the next check that reaches the server:
class LicenseManager {
// storage: anything with getItem/setItem/removeItem - localStorage in a
// browser, or your own file-backed equivalent in Node.
constructor(licenseClient, { gracePeriodMs = 24 * 60 * 60 * 1000, storage = globalThis.localStorage } = {}) {
this.client = licenseClient;
this.gracePeriodMs = gracePeriodMs;
this.storage = storage;
}
async checkLicense(key) {
const result = await this.client.verifyLicense(key);
if (result.state === 'licensed') {
this.saveCache(key, result.license);
return result;
}
if (result.state === 'refused') {
this.clearCache();
return result;
}
// No answer: fall back to the last good result, if it's recent enough.
const cached = this.loadCache();
if (cached && cached.key === key && Date.now() - cached.timestamp <= this.gracePeriodMs) {
return { state: 'licensed', cached: true, license: cached.license };
}
// Still no answer. Not a refusal: tell the user the license couldn't
// be checked, not that it's invalid.
return result;
}
saveCache(key, license) {
try {
this.storage?.setItem('license_cache', JSON.stringify({ key, license, timestamp: Date.now() }));
} catch {
// Storage unavailable: carry on without a cache.
}
}
loadCache() {
try {
const cached = this.storage?.getItem('license_cache');
return cached ? JSON.parse(cached) : null;
} catch {
return null;
}
}
clearCache() {
try {
this.storage?.removeItem('license_cache');
} catch {
// Nothing to clear.
}
}
}For an app that must keep working offline for longer than a grace period, use an offline license file instead of a cached API response.
2. User Notification System
class UserNotificationManager {
constructor() {
this.notifications = [];
}
showError(message, type = 'error', duration = 5000) {
const notification = {
id: Date.now(),
message,
type,
timestamp: new Date()
};
this.notifications.push(notification);
this.displayNotification(notification);
if (duration > 0) {
setTimeout(() => this.removeNotification(notification.id), duration);
}
return notification.id;
}
// result is what licenseClient.verifyLicense() returns
showLicenseError(result) {
let message;
if (result.state === 'no-answer') {
message = result.status === 429
? 'Too many license checks. The app will check again shortly.'
: 'Unable to verify license. Check your internet connection.';
return this.showError(message, 'warning');
}
if (result.reason.includes('expired')) {
message = 'Your license has expired. Please renew to continue using the application.';
} else if (result.status === 404) {
message = 'Invalid license key. Please check your key and try again.';
} else {
message = `License error: ${result.reason}`;
}
return this.showError(message, 'error', 0); // Don't auto-hide
}
displayNotification(notification) {
// Implementation depends on your UI framework
console.log(`[${notification.type.toUpperCase()}] ${notification.message}`);
}
removeNotification(id) {
this.notifications = this.notifications.filter(n => n.id !== id);
}
}Testing Error Scenarios
Unit Tests for Error Handling
Tests for the LicenseClient above. A 404 or 429 is a response, not a rejected promise, so mock it as a resolved Response; mock a network failure as a rejected TypeError, which is what fetch throws:
// Using Jest on Node 18+ (fetch and Response are built in)
const PRODUCT_UUID = 'b3f1c9a0-1234-4abc-9def-e2d4f5a6b7c8';
const jsonResponse = (status, body, headers = {}) =>
new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json', ...headers }
});
describe('License Error Handling', () => {
let licenseClient;
beforeEach(() => {
global.fetch = jest.fn();
licenseClient = new LicenseClient('https://test-api.com/v1', PRODUCT_UUID, { retryDelayMs: 0 });
});
test('a valid license for this product is licensed', async () => {
fetch.mockResolvedValueOnce(jsonResponse(200, { valid: true, product_uuid: PRODUCT_UUID }));
const result = await licenseClient.verifyLicense('valid-key');
expect(result.state).toBe('licensed');
});
test('an unknown license key is refused', async () => {
fetch.mockResolvedValueOnce(jsonResponse(404, { valid: false, error: 'License key not found' }));
const result = await licenseClient.verifyLicense('unknown-key');
expect(result.state).toBe('refused');
expect(result.reason).toContain('License key not found');
});
test('a license for another product is refused', async () => {
fetch.mockResolvedValueOnce(jsonResponse(200, { valid: true, product_uuid: 'some-other-product' }));
const result = await licenseClient.verifyLicense('other-product-key');
expect(result.state).toBe('refused');
});
test('a 429 is no answer, and is not retried', async () => {
fetch.mockResolvedValueOnce(
jsonResponse(429, { statusCode: 429, error: 'Too Many Requests' }, { 'Retry-After': '897' })
);
const result = await licenseClient.verifyLicense('any-key');
expect(result.state).toBe('no-answer');
expect(result.retryAfterSeconds).toBe(897);
expect(fetch).toHaveBeenCalledTimes(1);
});
test('a 5xx is retried, then no answer', async () => {
fetch.mockImplementation(() => Promise.resolve(jsonResponse(500, { error: 'Failed to verify license' })));
const result = await licenseClient.verifyLicense('any-key');
expect(result.state).toBe('no-answer');
expect(fetch).toHaveBeenCalledTimes(3);
});
test('a network error is retried, then no answer', async () => {
fetch.mockRejectedValue(new TypeError('fetch failed'));
const result = await licenseClient.verifyLicense('any-key');
expect(result.state).toBe('no-answer');
expect(fetch).toHaveBeenCalledTimes(3);
});
});Monitoring and Alerting
Health Check Implementation
class LicenseHealthMonitor {
// apiBaseUrl is the server's root, e.g. "https://your-license-api.com" -
// no method named healthCheck() exists on any client class; this hits
// the real, unauthenticated GET / health route directly (see
// Health Endpoints in the API Reference — that route has no /v1 prefix).
constructor(apiBaseUrl) {
this.apiBaseUrl = apiBaseUrl;
this.healthStatus = 'unknown';
this.lastCheck = null;
this.errorCount = 0;
this.successCount = 0;
}
async performHealthCheck() {
try {
const start = Date.now();
const response = await fetch(this.apiBaseUrl);
if (!response.ok) throw new Error(`Health check failed: ${response.status}`);
const duration = Date.now() - start;
this.healthStatus = 'healthy';
this.successCount++;
this.lastCheck = new Date();
return {
status: 'healthy',
responseTime: duration,
lastCheck: this.lastCheck
};
} catch (error) {
this.healthStatus = 'unhealthy';
this.errorCount++;
this.lastCheck = new Date();
return {
status: 'unhealthy',
error: error.message,
lastCheck: this.lastCheck,
errorCount: this.errorCount
};
}
}
getHealthMetrics() {
return {
status: this.healthStatus,
lastCheck: this.lastCheck,
errorCount: this.errorCount,
successCount: this.successCount,
uptime: this.successCount / (this.successCount + this.errorCount)
};
}
}Best Practices
Error Categorization
- Refused (
4xxother than429: unknown key, revoked, expired, no seats left, admin auth failures, or a license for a different product): don't retry; act on it and drop any cached result - No answer (network errors, timeouts,
5xx): retry a few times with a short backoff, then fall back to the last known state - Rate limited (
429): also no answer, but don't retry untilRetry-Afterseconds have passed — and don't block on it inline - Degraded service: use cached data or an offline license file, and show a warning, not an "invalid license" error
For general integration best practices (performance, security, monitoring, UX), see the Integration Checklist.
Next Steps
- Integration patterns for robust license checking
- API reference for complete error response documentation
- Rate limits guide for avoiding 429 errors