1. Switch on `code`, not the message
Every 4xx and 5xx is JSON of the form { "error": { "code", "message" } }. The HTTP status and the code string are stable contract; the message is human-readable and may change.
switch (error.code) {
case 'BUSY':
case 'TIMEOUT':
case 'RATE_LIMITED':
// retry-safe — honour Retry-After
break;
case 'NOT_FOUND':
case 'DOB_MISMATCH':
case 'INVALID_INPUT':
// user-input problem — surface to your end user, don't retry
break;
case 'PAYMENT_REQUIRED':
case 'QUOTA_EXCEEDED':
// billing problem — page your admin, then upgrade or wait for reset
break;
case 'UNAUTHENTICATED':
case 'INVALID_KEY':
// your key is missing/wrong — investigate, don't retry
break;
case 'GOVUK_UNEXPECTED':
case 'INTERNAL':
// open an issue with X-Request-Id; safe to retry once
break;
}2. Honour `Retry-After`
When the server says 503 BUSY or 429 RATE_LIMITED it includes a Retry-After header (seconds). Sleep that long and retry — don't hammer.
async function checkWithBackoff(body: object, key: string, maxAttempts = 3) {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const res = await fetch('https://api.rtwchecker.dev/api/check', {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: `Bearer ${key}` },
body: JSON.stringify(body),
});
if (res.ok) return res.json();
if (res.status === 503 || res.status === 429) {
const retry = Number(res.headers.get('retry-after') ?? '5');
await new Promise((r) => setTimeout(r, retry * 1000));
continue;
}
throw new Error(`${res.status}: ${(await res.json()).error.code}`);
}
throw new Error('Exhausted retries');
}3. Log `X-Request-Id`
Every response carries an X-Request-Id header. Log it alongside your own trace ID so support can cross-reference our audit log if a request misbehaves.
4. When to surface to the end user
Only NOT_FOUND, DOB_MISMATCH, and INVALID_INPUT indicate something the applicant or your form needs to correct. All other codes are your team's problem to handle.