Request options and errors

Every service call takes the same options on top of its own parameters: request validation, overrides of the global configuration and a signal to cancel it. Each call runs these steps in order:

  1. An already-aborted signal rejects at once, before any work.
  2. The global configuration is merged under the call’s parameters.
  3. The resulting commonBaseURL is checked, even with validateRequest: false. An insecure one rejects with INSECURE_BASE_URL.
  4. The parameters are validated, unless validateRequest: false.
  5. The request is built.
  6. The request is sent, and a 429 is retried as the call’s and the global retry set.
  7. The response is parsed.

Request validation

Before sending, the SDK checks the parameters against the service’s schema. A malformed call fails early with a message in the SDK’s terms, and never reaches the API or your usage quota.

import { discoverPlaces } from '@tomtom-org/maps-sdk/services';
await discoverPlaces({ query: 'Eiffel Tower', validateRequest: false });
  • validateRequest: false skips the check for that call. This saves CPU when you know the inputs are good.
  • A failed check rejects with an SDKError whose issues lists the Zod issues.
  • Keep it on while developing.

Per-call overrides

You can pass the API key, language, geopolitical view, base URL and retry behaviour of the global configuration to a single call instead. They override the global values for that call only:

// French for this call only; later calls keep the global language
await discoverPlaces({ query: 'restaurants', language: 'fr-FR' });
// Give up on a rate-limited search after 2 seconds; the global wait and factor still apply
await getSearchSuggestions({ query: 'coffee', geoBias: { position: [4.9041, 52.3676] }, retry: { timeoutMs: 2000 } });
  • A call’s retry overrides the global retry field by field, so retry: { timeoutMs: 2000 } keeps the global wait and factor. To turn retrying off for one call, pass retry: undefined.
  • Each service fixes its own endpoint path and API version. You can’t override either.
  • To send every request through a proxy that holds the key server-side, set the global commonBaseURL. See Proxying your API calls.

Cancelling a request

Every call accepts a signal, an AbortSignal. Aborting it cancels the HTTP request and rejects the call with an SDKAbortError, whose name is 'AbortError'.

  • Aborting also cancels a pending 429 retry, so an aborted call never waits out its backoff.
  • An already-aborted signal rejects before the request is built, so nothing is sent.
  • discoverOnePlace and geocodeOne take the signal in their params form, as in discoverOnePlace({ query, signal }).
  • calculateReachableRanges takes it as a second argument, for all its ranges at once.

Keeping only the latest request

When a new request supersedes an older one (a click lookup, a search-as-you-type box, a search re-run on every map move), a slow earlier response can land after a faster later one and overwrite it.

createLatestRequest holds one such request at a time. Each run aborts the one before it and hands your callback a fresh signal. cancel aborts without starting another:

import { createLatestRequest, reverseGeocode } from '@tomtom-org/maps-sdk/services';
const latestLookup = createLatestRequest();
map.mapLibreMap.on('click', async (event) => {
const lookup = await latestLookup.run((signal) =>
reverseGeocode({ position: event.lngLat.toArray(), signal })
);
if (!lookup.current) return; // a newer click or a cancel retired it
showAddress(lookup.value);
});
clearButton.addEventListener('click', latestLookup.cancel);
  • A retired request resolves { current: false } and never rejects, so there is no SDKAbortError to catch.
  • An answer that lands after a cancel is also dropped. A bare signal can’t do this once the response is in.
  • It rethrows the current request’s errors, including a deadline composed onto the signal, so a timeout still reads as a failure.
  • One callback can chain several calls on the same signal. A newer run drops the whole chain, wherever it got to.
  • isRunning() reports whether a request is in flight. Use one createLatestRequest() per thing that can be superseded independently.
  • It cancels, but it does not debounce. Every run still sends its request. To send fewer while the user types or drags, debounce the input and call run from the debounced handler.

Deadlines

There is no timeout option. Instead, compose a deadline onto the signal, alone or with your own cancellation:

import { calculateRoute, createLatestRequest, getSearchSuggestions } from '@tomtom-org/maps-sdk/services';
// A short deadline for a search box
await getSearchSuggestions({
query: 'coffee',
geoBias: { position: [4.9041, 52.3676] },
signal: AbortSignal.timeout(3000)
});
// A longer one for a route, still retired by the next run
const latestRoute = createLatestRequest();
const route = await latestRoute.run((signal) =>
calculateRoute({
locations: [[4.9041, 52.3676], [2.3522, 48.8566]],
signal: AbortSignal.any([signal, AbortSignal.timeout(15000)])
})
);
  • A deadline that fires rejects with SDKAbortError, like a manual abort. Its cause tells the two apart. It holds the signal’s reason, which is a TimeoutError for AbortSignal.timeout.
  • Under createLatestRequest, the deadline is the only abort that rejects. A retired run resolves { current: false }.
  • The global retry.timeoutMs is unrelated. It limits how long the SDK retries a 429.

Error handling

A service call rejects with an SDKError. A request the service can’t build at all, such as a geometry type it does not search sent with validateRequest: false, rejects with a plain Error instead. An SDKError’s code says what went wrong, service names the service that failed as a ServiceName such as 'Geocode' or 'ReachableRange', and sdkVersion gives the SDK version for bug reports:

codeWhen
INVALID_REQUESTThe SDK’s request validation failed (issues lists why), or the API answered 400 or another 4xx not listed below
INSECURE_BASE_URLcommonBaseURL is neither https: nor http: on a loopback address
INVALID_CREDENTIALSThe API answered 401, because the key is missing, invalid or expired
INSUFFICIENT_PERMISSIONSThe API answered 403, because the key is valid but not allowed this request. See A key refused access
NOT_FOUNDThe API answered 404
RATE_LIMITEDThe API still answered 429 after the retries
LIVE_SERVICE_NOT_AVAILABLEThe API answered a 5xx
NETWORK_NOT_AVAILABLENo response arrived: offline, DNS, TLS or CORS failure. cause holds what fetch threw
ABORTEDThe signal was aborted. cause holds the abort reason
INTERNALAnything else, such as a response the SDK could not read

Two subclasses carry more:

ErrorWhenCarries
SDKServiceErrorThe API answered with an error statusstatus, apiErrorCode (the API’s own name for the error, when it gave one) and privatePreviewAccess
SDKAbortErrorThe signal was abortedcause, the abort reason

discoverOnePlace and geocodeOne also throw a plain Error when nothing matches the query. To get an empty result instead, use discoverPlaces or geocode.

import { discoverPlaces, SDKError } from '@tomtom-org/maps-sdk/services';
try {
const results = await discoverPlaces({
query: 'coffee',
geoBias: { position: [4.9041, 52.3676] },
signal: AbortSignal.timeout(5000)
});
} catch (error) {
if (!(error instanceof SDKError)) throw error;
switch (error.code) {
case 'ABORTED':
console.error('Search timed out');
break;
case 'INVALID_CREDENTIALS':
console.error('Check the API key');
break;
case 'INSUFFICIENT_PERMISSIONS':
console.error(`Check the APIs and domains the key allows: ${error.message}`);
break;
case 'RATE_LIMITED':
console.error('Rate limited, even after retrying');
break;
case 'NETWORK_NOT_AVAILABLE':
console.error('No connection');
break;
default:
console.error(`${error.service} failed (${error.code}, SDK ${error.sdkVersion}): ${error.message}`);
}
}
  • Branch on code, not on message. The codes are the stable part. sdkErrorCodes (core) lists them all, under the names the TomTom Navigation SDK gives the same failures.
  • canRetry is true when the failure clears on its own. That covers RATE_LIMITED, LIVE_SERVICE_NOT_AVAILABLE and NETWORK_NOT_AVAILABLE, where the same call is likely to succeed later. On any other code, retrying fails the same way.
  • A 429 that rejects has already been retried for as long as retry allows.
  • On a 429, the SDK replaces the API’s message with a standard one.
  • The map reports its own failures with the same codes. See Map errors.

The code below geocodes a batch of addresses in parallel under one deadline, and reads each failure from Promise.allSettled by its code and canRetry. Swap the latitude-first position for [139.76, 35.68] and that address resolves:

import { TomTomConfig } from '@tomtom-org/maps-sdk/core';
import { type GeocodingParams, geocodeOne, SDKError, SDKServiceError } from '@tomtom-org/maps-sdk/services';
import { API_KEY } from './config';

// A rate-limited (429) call is retried for up to 5 seconds before it rejects with RATE_LIMITED.
TomTomConfig.instance.put({ apiKey: API_KEY, retry: { timeoutMs: 5000 } });

const batch: Omit<GeocodingParams, 'limit'>[] = [
    { query: 'Damrak 1, Amsterdam' },
    { query: 'Rue de Rivoli 99, Paris', filters: { countries: ['FR'] } },
    { query: 'Marunouchi 1-1, Tokyo', geoBias: { position: [35.68, 139.76] } }, // latitude first by mistake
    { query: 'Unter den Linden 77, Berlin' },
    { query: 'qzxqzxqzx' },
    { query: 'Plaza Mayor, Madrid' },
];

const describeFailure = (error: unknown) => {
    if (!(error instanceof SDKError)) return `not an SDKError: ${error instanceof Error ? error.message : error}`;

    const origin = error instanceof SDKServiceError ? `the API answered ${error.status}` : 'raised by the SDK';
    const detail =
        error.issues?.map((issue) => `${issue.path.join('.')}: ${issue.message}`).join('; ') ?? error.message;
    return `${error.code}, ${origin}, canRetry ${error.canRetry} (${detail})`;
};

(async () => {
    const signal = AbortSignal.timeout(10000);
    const results = await Promise.allSettled(batch.map((request) => geocodeOne({ ...request, signal })));

    results.forEach((result, index) => {
        const { query } = batch[index];
        if (result.status === 'fulfilled') {
            const { geometry, properties } = result.value;
            console.log(`OK    ${query}: ${properties.address.freeformAddress} [${geometry.coordinates}]`);
        } else {
            console.log(`FAIL  ${query}: ${describeFailure(result.reason)}`);
        }
    });
})();

A key refused access

A 401 and a 403 are two different problems. INVALID_CREDENTIALS means the API doesn’t know the key. INSUFFICIENT_PERMISSIONS means the key is valid but may not make this request. apiErrorCode names the cause of a 403:

apiErrorCodeCauseFix
'Forbidden'The API is not enabled for the keyEnable it for the key, or for a Private Preview API, request access
'InvalidReferer'The key’s domain restriction refused the pageAdd the page’s domain to the key, or set a fixed referrer from your proxy

A Private Preview API, such as Calculate Reachable Range, needs access granted to the key. The Freemium and Pay As You Grow plans don’t include it. When such an API answers 'Forbidden', or a proxy answers a 403 with no body, the error says so in two places:

  • The message ends with the API that needs access, a link to the Private Preview terms and where to request the access.
  • privatePreviewAccess carries the same information as data, { product, requestAccessURL }, so you can show your own message:
import { calculateReachableRange, SDKServiceError } from '@tomtom-org/maps-sdk/services';
try {
await calculateReachableRange({ origin: [4.9041, 52.3676], budget: { type: 'timeMinutes', value: 30 } });
} catch (error) {
if (error instanceof SDKServiceError && error.privatePreviewAccess) {
const { product, requestAccessURL } = error.privatePreviewAccess;
showMessage(`${product} needs access on your API key: ${requestAccessURL}`);
} else throw error;
}