Error Handling
The Quote Gallery API uses standard HTTP status codes and returns consistent JSON error bodies. Every error response looks the same:
{
"error": "Human-readable message",
"status": 401
}
Error codes
Client errors (4xx)
| Status | Name | When it happens |
|---|---|---|
400 | Bad Request | Malformed params — limit over 100, negative offset, invalid type |
401 | Unauthorized | Missing or invalid X-API-Key header |
403 | Forbidden | Key is valid but lacks permission for this resource |
404 | Not Found | ID doesn't exist or the resource isn't publicly approved |
429 | Too Many Requests | Hourly quota exhausted |
Server errors (5xx)
| Status | Name | What to do |
|---|---|---|
500 | Internal Server Error | Retry. If it persists, contact support |
502 | Bad Gateway | Temporary upstream issue. Retry with backoff |
503 | Service Unavailable | Maintenance or high load. Retry after a short delay |
Common mistakes
401 with a valid key — check for extra whitespace, truncation, or a Bearer prefix. The API expects the raw key in X-API-Key, not an Authorization header.
404 on a known ID — the resource may exist but not be publicly approved (drafts, rejected content). Only approved public content is visible via the API.
400 on pagination — offset must be >= 0 and limit must be between 1 and 100.
Handling errors by status code
Different errors call for different responses. 4xx errors (except 429) are your fault and shouldn't be retried. 5xx errors and 429s are retryable.
async function safeApiFetch(url, apiKey) {
const response = await fetch(url, {
headers: { 'X-API-Key': apiKey },
})
if (response.ok) {
return { success: true, data: await response.json() }
}
const error = await response.json()
switch (response.status) {
case 400:
return { success: false, error: error.error, retryable: false }
case 401:
return { success: false, error: error.error, retryable: false }
case 404:
return { success: false, error: error.error, retryable: false }
case 429: {
const reset = response.headers.get('X-RateLimit-Reset')
const retryAfter = reset
? Math.max((Number(reset) * 1000) - Date.now(), 1000)
: 60000
return { success: false, error: error.error, retryable: true, retryAfter }
}
default:
return { success: false, error: error.error, retryable: true }
}
}
Retry with exponential backoff
For 429 and 5xx responses, wait before retrying. Use X-RateLimit-Reset when available:
async function fetchWithBackoff(url, apiKey, maxRetries = 3) {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const response = await fetch(url, {
headers: { 'X-API-Key': apiKey },
})
if (response.ok) return response.json()
if (response.status !== 429 && response.status < 500) {
const error = await response.json()
throw new Error(`API Error ${response.status}: ${error.error}`)
}
if (attempt < maxRetries) {
const baseDelay = response.status === 429 ? 5000 : 1000
const delay = baseDelay * Math.pow(2, attempt) + Math.random() * 1000
await new Promise((resolve) => setTimeout(resolve, delay))
}
}
throw new Error(`Failed after ${maxRetries + 1} attempts`)
}
A complete API client
This client handles timeouts, retries, and structured errors out of the box:
class QuoteGalleryClient {
constructor(apiKey, options = {}) {
this.apiKey = apiKey
this.baseUrl = options.baseUrl ?? 'https://quotegallery.nl/api/v1'
this.maxRetries = options.maxRetries ?? 3
this.timeout = options.timeout ?? 10000
}
async request(endpoint, params = {}) {
const url = new URL(`${this.baseUrl}${endpoint}`)
Object.entries(params).forEach(([k, v]) => {
if (v !== undefined && v !== null) url.searchParams.set(k, String(v))
})
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), this.timeout)
try {
const response = await fetch(url.toString(), {
headers: { 'X-API-Key': this.apiKey },
signal: controller.signal,
})
clearTimeout(timer)
if (response.ok) return response.json()
const error = await response.json()
if (response.status !== 429 && response.status < 500) {
throw new ApiError(error.error, response.status)
}
if (attempt < this.maxRetries) {
const delay = this.#retryDelay(response, attempt)
await new Promise((r) => setTimeout(r, delay))
continue
}
throw new ApiError(error.error, response.status)
} catch (err) {
clearTimeout(timer)
if (err instanceof ApiError) throw err
if (err.name === 'AbortError') throw new ApiError('Request timed out', 408)
throw new ApiError(`Network error: ${err.message}`, 0)
}
}
}
#retryDelay(response, attempt) {
if (response.status === 429) {
const reset = response.headers.get('X-RateLimit-Reset')
if (reset) return Math.max((Number(reset) * 1000) - Date.now(), 1000)
}
return Math.min(1000 * Math.pow(2, attempt) + Math.random() * 1000, 30000)
}
getQuotes(params) { return this.request('/quotes', params) }
getRandomQuote(params) { return this.request('/quotes/random', params) }
getQuote(id, params) { return this.request(`/quotes/${id}`, params) }
getAuthors(params) { return this.request('/authors', params) }
getAuthor(id, params) { return this.request(`/authors/${id}`, params) }
getPlaylists(params) { return this.request('/playlists', params) }
getPlaylist(id, params) { return this.request(`/playlists/${id}`, params) }
getCategories() { return this.request('/categories') }
}
class ApiError extends Error {
constructor(message, statusCode) {
super(message)
this.name = 'ApiError'
this.statusCode = statusCode
}
}
const client = new QuoteGalleryClient(process.env.QUOTE_GALLERY_API_KEY)
try {
const { data: quotes } = await client.getQuotes({ language: 'en', categories: 'wisdom', limit: 10 })
quotes.forEach((q) => console.log(`"${q.text}" — ${q.author.name}`))
} catch (err) {
if (err instanceof ApiError) {
console.error(`API Error ${err.statusCode}: ${err.message}`)
}
}