Getting started

Errors

Every failure is a typed error carrying the stable machine-readable code from the API. Branch on the code, never on the message.

The SDK throws two error classes and nothing else. Both are real Error subclasses, so stack traces and instanceof behave normally.

ClassThrown when
EnvlessApiErrorThe API answered and the answer was a failure. Carries status, code and the request id.
EnvlessNetworkErrorNothing came back to classify: connection refused, DNS failure, timeout, abort.
ts
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

PropertyTypeNotes
statusnumber | nullThe HTTP status.
codestringThe stable code, such as variable.ciphertext_invalid. Always present, falling back to server.error.
resourcestring | undefinedWhat the failure relates to. On a scope failure this is the exact scope needed, such as variables:write.
fieldstring | undefinedThe request field that caused it.
requestIdstring | undefinedRead from _req.reqId. This is what support needs. Log it.
retryAfterSecondsnumber | undefinedFrom the Retry-After header, on a 429 or a transient 5xx.

Convenience getters

GetterTrue when
isAuthcode starts with auth.
isScopeMissingcode is exactly auth.scope_missing
isValidationstatus is 400
needsUpgradestatus is 402
isNotFoundstatus is 404
isConflictstatus is 409
isRateLimitedstatus 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.

CodeStatusWhat to do
auth.api_key_not_found401The key is invalid, inactive or revoked. Stop, do not retry.
auth.scope_missing403Mint a key with error.resource.
auth.scope_ceiling_exceeded403You asked for scopes the minting key does not hold.
auth.key_project_restricted403The key is fenced to other projects.
auth.product_restricted403The key owner has no grant on a private product.
request.body_invalid400Fix the payload. error.field names the offender.
request.invalid_pagination400limit outside 1 to 100, or a non-integer.
variable.ciphertext_invalid400You sent plaintext. See Encryption.
variable.baseline_drifted409Somebody edited since you read the baseline. Re-read and retry.
project.ambiguous_product409Pass { product }.
plan.upgrade_required402A tier limit or a paid feature, such as an older version or a rollback on Free. An upgrade fixes it. Not retryable.
plan.limit_reached409The 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.

ts
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.

ts
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.

ts
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
}