Error Handling

Catch listingsapi-js error classes, read platform error codes, and build retryAfter-aware backoff.

Every error the SDK throws derives from ListingsAPIError. API failures throw APIError or one of its subclasses, whether the platform reports them as a non-2xx HTTP status or inside an HTTP 200 body. Network failures (DNS errors, refused connections, timeouts) throw APIConnectionError. You never inspect success flags or raw payloads: a resolved promise means the call succeeded.

Error hierarchy

ListingsAPIError                 base class for everything the SDK throws
├── APIError                     any failure reported by the API
│   ├── AuthenticationError      401, or invalid-token error payloads
│   ├── PermissionDeniedError    403, or permission error payloads
│   ├── NotFoundError            404, resource not found
│   ├── ValidationError          400/422, mutations reporting success=false
│   │                            or errors[], and client-side payload checks
│   ├── RateLimitError           429, carries retryAfter
│   └── InternalServerError      5xx, safe to retry
└── APIConnectionError           network failure, nothing reached the API

All classes are exported from the package root:

TypeScript
import {
ListingsAPIError,
APIError,
AuthenticationError,
PermissionDeniedError,
NotFoundError,
ValidationError,
RateLimitError,
InternalServerError,
APIConnectionError,
} from 'listingsapi-js';

APIError properties

PropertyTypeDescription
statusCodenumber | nullHTTP status of the failed response: 401, 429, 500, or 200 when the error arrived inside a 200 body. null for client-side validation failures.
codestring | nullFirst platform error code, e.g. SY10005, when the API sent one.
errorsApiErrorEntry[]Every error entry, normalized to { code, message, context }.
responseBodystring | nullRaw response body as a string.
retryAfternumber | nullRateLimitError only. Seconds to wait before retrying, parsed from the Retry-After header, or null when the header was absent.
messagestringHuman-readable summary with the error entries (or raw body) appended.
namestringThe concrete class name, e.g. "RateLimitError".
TypeScript
import { ListingsAPI, APIError, APIConnectionError } from 'listingsapi-js';
 
const client = new ListingsAPI();
 
try {
await client.fetchAllLocations({ first: 10 });
} catch (error) {
if (error instanceof APIError) {
console.error(error.statusCode); // 401, 429, 500, or 200 for payload errors
console.error(error.code); // first platform code, e.g. 'SY10005'
console.error(error.errors); // [{ code, message, context }, ...]
console.error(error.responseBody); // raw body text
} else if (error instanceof APIConnectionError) {
console.error('Nothing reached the API:', error.message);
}
}

Catching errors by type

Authentication failures

TypeScript
import { AuthenticationError } from 'listingsapi-js';
 
try {
await client.fetchAllLocations({ first: 1 });
} catch (error) {
if (error instanceof AuthenticationError) {
console.error('Invalid API key. Rotate it under API Keys in the dashboard.');
}
}

Missing resources

TypeScript
import { NotFoundError } from 'listingsapi-js';
 
try {
await client.fetchPremiumListings(99999999);
} catch (error) {
if (error instanceof NotFoundError) {
console.error('Location not found.');
}
}

Rate limits

RateLimitError.retryAfter holds the server's Retry-After value in seconds, or null when the header was absent:

TypeScript
import { RateLimitError } from 'listingsapi-js';
 
try {
await client.fetchAllLocations({ first: 100 });
} catch (error) {
if (error instanceof RateLimitError) {
const waitSeconds = error.retryAfter ?? 5;
console.error('Rate limited, retry in ' + waitSeconds + 's');
}
}

Validation failures

Server-side validation failures carry the platform's error entries:

TypeScript
import { ValidationError } from 'listingsapi-js';
 
try {
await client.updateLocation({ id: 16808, phone: 'not-a-phone-number' });
} catch (error) {
if (error instanceof ValidationError) {
for (const entry of error.errors) {
console.error((entry.code ?? 'unknown') + ': ' + entry.message);
}
}
}

Errors inside HTTP 200 responses

The platform reports some failures with HTTP 200 and an error payload in the body. The SDK inspects every response and throws for these too, so a 200 status never means "check the body yourself":

Payload signalThrown as
Top-level errors[] with code SY90005 or SY90001AuthenticationError
Top-level errors[] with code SY90003PermissionDeniedError
Top-level errors[] with code RATE_LIMITEDRateLimitError
Any other top-level errors[]APIError
Mutation result with errors[] or success: falseValidationError

For mutations (POST and DELETE helpers), the SDK checks every result object in the response envelope; if any reports success: false or carries errors[], it throws ValidationError with statusCode set to 200.

TypeScript
import { ValidationError } from 'listingsapi-js';
 
try {
const result = await client.archiveLocations([16808]);
console.log('Archived:', result);
} catch (error) {
if (error instanceof ValidationError) {
// The API answered 200 but rejected the mutation
console.error(error.statusCode); // 200
console.error(error.message);
}
}

Client-side validation in the post helpers

The post helpers (createAnnouncement, createEvent, createOffer, and bulkPublish) validate their payload locally and throw ValidationError before any network call:

  • name must be non-blank and locationIds must not be empty.
  • sites must be a non-empty subset of GOOGLE and FACEBOOK.
  • message is required; when it is a per-site map, every targeted site needs a non-blank entry.
  • ctaType and ctaUrl must be provided together, and ctaType must be one of BOOK, ORDER, SHOP, LEARN_MORE, SIGN_UP, GET_OFFER.
  • createEvent and createOffer additionally require title.
  • postType (on bulkPublish) must be one of ANNOUNCEMENT, EVENT, OFFER, COVID19, PRODUCT.

These local errors have statusCode: null, which is how you tell them apart from a server-side rejection. The low-level createPost(body) method sends its raw body without these checks.

TypeScript
import { ListingsAPI, ValidationError } from 'listingsapi-js';
 
const client = new ListingsAPI();
 
try {
await client.createAnnouncement({
name: 'Grand opening',
locationIds: [16808],
message: 'Acme Dental is now open in Midtown!',
ctaType: 'BOOK', // ctaUrl missing: throws before any request is sent
});
} catch (error) {
if (error instanceof ValidationError && error.statusCode === null) {
console.error(error.message);
// Invalid post: ctaType and ctaUrl must be provided together
}
}

Built-in retries

The client itself retries 429 and 5xx responses (honoring the Retry-After header) and network failures, twice by default. Errors reach your code only after those attempts are exhausted. See Configuration for the maxRetries and timeout options.

A retryAfter-aware backoff recipe

For long-running jobs that should outlast the client's built-in retries, wrap calls in a backoff loop that prefers the server's retryAfter hint and falls back to exponential delays:

backoff.ts
import {
ListingsAPI,
RateLimitError,
InternalServerError,
APIConnectionError,
} from 'listingsapi-js';
 
const client = new ListingsAPI();
 
async function withBackoff<T>(
fn: () => Promise<T>,
maxAttempts = 5,
): Promise<T> {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (err) {
const retryable =
err instanceof RateLimitError ||
err instanceof InternalServerError ||
err instanceof APIConnectionError;
if (!retryable || attempt === maxAttempts) throw err;
 
let delayMs = 500 * 2 ** (attempt - 1);
if (err instanceof RateLimitError && err.retryAfter !== null) {
delayMs = err.retryAfter * 1000;
}
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}
throw new Error('unreachable');
}
 
const page = await withBackoff(() => client.fetchAllLocations({ first: 25 }));

NotFoundError, ValidationError, AuthenticationError, and PermissionDeniedError are deliberately not retried: repeating those requests cannot succeed without changing the input or the key.

Network failures and timeouts

Anything that prevents a response from arriving (DNS failure, refused connection, or the per-request timeout firing) throws APIConnectionError. The SDK never leaks the native TypeError or AbortError from fetch.

Timeouts are enforced internally with AbortSignal.timeout on every attempt, 240000 ms by default, configurable via the timeout constructor option. A timed-out attempt is retried like any other connection failure; only the final failure surfaces:

TypeScript
import { ListingsAPI, APIConnectionError } from 'listingsapi-js';
 
const client = new ListingsAPI({ timeout: 15000 });
 
try {
await client.fetchAllLocations({ first: 10 });
} catch (error) {
if (error instanceof APIConnectionError) {
console.error('Network failure or timeout:', error.message);
}
}