The SDK throws two error classes and nothing else. Both are real Error subclasses, so stack traces and instanceof behave normally.
| Class | Thrown when |
|---|---|
EnvlessApiError | The API answered and the answer was a failure. Carries status, code and the request id. |
EnvlessNetworkError | Nothing came back to classify: connection refused, DNS failure, timeout, abort. |
import { EnvlessApiError, EnvlessNetworkError } from '@goenvless/sdk'
try {
await envless.projects.create({ name: 'Billing', slug: 'billing' })
}
catch (error) {
if (error instanceof EnvlessApiError) {
if (error.needsUpgrade) console.error('Upgrade needed:', error.message)
else if (error.isScopeMissing) console.error('Key needs:', error.resource)
else if (error.isConflict) console.error('Slug already taken')
else console.error(error.code, error.requestId)
}
else if (error instanceof EnvlessNetworkError) {
console.error('Could not reach the API:', error.message)
}
else throw error
}Never branch on error.message. It is written for humans and can be reworded at any time. error.code is a stable contract.
EnvlessApiError
| Property | Type | Notes |
|---|---|---|
status | number | null | The HTTP status. |
code | string | The stable code, such as variable.ciphertext_invalid. Always present, falling back to server.error. |
resource | string | undefined | What the failure relates to. On a scope failure this is the exact scope needed, such as variables:write. |
field | string | undefined | The request field that caused it. |
requestId | string | undefined | Read from _req.reqId. This is what support needs. Log it. |
retryAfterSeconds | number | undefined | From the Retry-After header, on a 429 or a transient 5xx. |
Convenience getters
| Getter | True when |
|---|---|
isAuth | code starts with auth. |
isScopeMissing | code is exactly auth.scope_missing |
isValidation | status is 400 |
needsUpgrade | status is 402 |
isNotFound | status is 404 |
isConflict | status is 409 |
isRateLimited | status is 429 |
The getters cover the branches most callers need. For anything finer, match on code.
The codes worth handling
Full tables live on each endpoint page. These are the ones that come up when you drive the API from a script.
| Code | Status | What to do |
|---|---|---|
auth.api_key_not_found | 401 | The key is invalid, inactive or revoked. Stop, do not retry. |
auth.scope_missing | 403 | Mint a key with error.resource. |
auth.scope_ceiling_exceeded | 403 | You asked for scopes the minting key does not hold. |
auth.key_project_restricted | 403 | The key is fenced to other projects. |
auth.product_restricted | 403 | The key owner has no grant on a private product. |
request.body_invalid | 400 | Fix the payload. error.field names the offender. |
request.invalid_pagination | 400 | limit outside 1 to 100, or a non-integer. |
variable.ciphertext_invalid | 400 | You sent plaintext. See Encryption. |
variable.baseline_drifted | 409 | Somebody edited since you read the baseline. Re-read and retry. |
project.ambiguous_product | 409 | Pass { product }. |
plan.upgrade_required | 402 | A tier limit or a paid feature, such as an older version or a rollback on Free. An upgrade fixes it. Not retryable. |
plan.limit_reached | 409 | The 1,000-variables-per-environment fair-use cap. No plan raises it; contact support. Not retryable. |
Retrying safely
The client already retries transient failures on idempotent methods. What it deliberately does not do is retry a POST or a PATCH, because a create that timed out may have landed. If you want to retry a write, make it safe first.
const Retry = async <T>(run: () => Promise<T>, attempts = 3): Promise<T> => {
for (let attempt = 1; ; attempt++) {
try {
return await run()
}
catch (error) {
const transient = error instanceof EnvlessNetworkError
|| (error instanceof EnvlessApiError && error.status !== null && error.status >= 500)
if (!transient || attempt >= attempts) throw error
await new Promise(resolve => setTimeout(resolve, 2 ** attempt * 250))
}
}
}For variables.apply the baseline makes this genuinely safe: a retry either applies cleanly or fails with variable.baseline_drifted and writes nothing.
const Sync = async (changes: () => Promise<void>, attempts = 3) => {
for (let attempt = 1; ; attempt++) {
try {
return await changes()
}
catch (error) {
const drifted = error instanceof EnvlessApiError && error.code === 'variable.baseline_drifted'
if (!drifted || attempt >= attempts) throw error
}
}
}Logging
Log the code and the request id, never the payload. Variable values are ciphertext, but names, member emails and workspace slugs are not.
catch (error) {
if (error instanceof EnvlessApiError) {
logger.error('envless request failed', {
code: error.code,
status: error.status,
resource: error.resource,
field: error.field,
requestId: error.requestId
})
}
throw error
}