Error handling
Error response structure
Most error responses return a JSON object with a message field. Validation errors (400) add an errors array naming the parameter at fault:
{
"message": "Bad request",
"errors": [
{
"key": "filterBehaviour",
"messages": [
"Parameter filterBehaviour is invalid. Valid values: allOf, anyOf"
]
}
]
}
These fields are for developer debugging only — drive your UI from the status code, not the message. Note that not every error body is JSON: 404 returns an empty body and firewall errors on pdn.epidemicsound.com return HTML, so parse defensively.
HTTP status codes
| Code | Name | When it occurs | Retry? | Safe to show users? |
|---|---|---|---|---|
| 400 | Bad Request | Missing or invalid request parameters (e.g. calling /v0/sound-effects/search without a term), or the request URL exceeds 4096 bytes (e.g. too many track IDs in a single call to /v0/tracks/metadata — batch in groups of 50) | No | No — show a generic "something went wrong" message |
| 401 | Unauthorized | Missing, expired, or invalid token | After re-authenticating | Prompt user to log in again |
| 403 | Forbidden | Valid API key but insufficient permissions for this resource (e.g. accessing an endpoint outside your plan) | No | No — this is a configuration issue |
| 404 | Not Found | The requested resource does not exist | No | You can show "not found" if the resource is user-visible |
| 406 | Not Acceptable | Firewall block on audio delivery (pdn.epidemicsound.com), usually a sustained download rate that resembles scraping. Not content negotiation — changing Accept won't help | Yes, after a 2s delay | No — retry silently |
| 429 | Too Many Requests | Rate limit exceeded — per-second, daily app, or per-user limit reached | Yes, with backoff | No — handle silently with a retry |
| 500 | Internal Server Error | An unexpected error occurred on the API side. May be transient or triggered by a specific request — retry once, then report if it persists | Once, then report | No — retry silently |
| 502 | Bad Gateway | An internal error — the request did not complete between our edge and the API. Usually transient | Yes, with backoff | No — retry silently |
| 503 | Service Unavailable | Temporary service outage | Yes, with backoff | No — retry silently |
Respect Retry-After
Any retryable response may carry a Retry-After header telling you how long to wait. Whenever you retry a request, respect that header if it is present and fall back to your own backoff schedule when it is absent. The header comes in two formats — a delay in seconds (Retry-After: 120) or an HTTP date (Retry-After: Wed, 21 Oct 2026 07:28:00 GMT) — so parse both.
A 406 is retryable, but wait at least 2 seconds first — retrying immediately from the same IP reinforces the block. If it persists across retries the IP may need unblocking manually, so contact us with your egress IPs and a time window.
For details on rate limit headers, retry strategies, and DDoS protection, see Troubleshooting.
Error handling example
async function errorMessage(response) {
try {
const body = await response.json()
return (
body.errors
?.map((e) => `${e.key}: ${e.messages.join(', ')}`)
.join('; ') ?? body.message
)
} catch {
return response.statusText // 404 is empty, firewall errors are HTML
}
}
// Retry-After is either a delay in seconds or an HTTP date
function retryAfterMs(response) {
const value = response.headers.get('Retry-After')
if (!value) return null
const seconds = Number(value)
if (!Number.isNaN(seconds)) return seconds * 1000
const date = Date.parse(value)
return Number.isNaN(date) ? null : Math.max(0, date - Date.now())
}
async function apiFetch(url, options, maxRetries = 3) {
let attempt = 0
while (attempt < maxRetries) {
const response = await fetch(url, options)
if (response.ok) {
return response.json()
}
const status = response.status
if (status === 406) {
// Firewall block — wait at least 2s, an immediate retry makes it worse
const waitMs = Math.max(retryAfterMs(response) ?? 0, 2000 * (attempt + 1))
await new Promise((resolve) => setTimeout(resolve, waitMs))
attempt++
continue
}
if (status === 401) {
// API key is missing or invalid — check your credentials and do not retry automatically
throw new Error('Invalid API key — check your credentials')
}
if (status === 429 || status === 502 || status === 503) {
// Respect Retry-After when present, otherwise back off exponentially
const waitMs = retryAfterMs(response) ?? Math.pow(2, attempt) * 1000
await new Promise((resolve) => setTimeout(resolve, waitMs))
attempt++
continue
}
if (status === 500) {
// Retry once — a 500 can be transient, but retrying repeatedly won't
// help if it's triggered by the request itself (e.g. accessing a
// resource outside your plan). Report it if it persists.
if (attempt === 0) {
await new Promise((resolve) =>
setTimeout(resolve, retryAfterMs(response) ?? 1000)
)
attempt++
continue
}
throw new Error(
`API error 500: ${await errorMessage(
response
)} (report this if it persists)`
)
}
// Non-retryable error
throw new Error(`API error ${status}: ${await errorMessage(response)}`)
}
throw new Error('Max retries exceeded')
}
Use the HTTP status code to drive UI decisions, not the message field. For example:
- 401: "Your session has expired. Please log in again."
- 404: "This track is no longer available."
- 429 / 502 / 503: Retry silently in the background; only show a message if all retries fail.
- 500: Retry once. If it keeps failing, report the error to Epidemic Sound.
- Everything else: "Something went wrong. Please try again later."