Error reference
Every error response from the Driftstack API is
an RFC 9457 Problem Details
JSON document. The type URI uniquely identifies the error class;
the status carries the HTTP status code; the detail field
explains what went wrong in human-readable text.
{
"type": "https://errors.driftstack.dev/rate-limited",
"title": "Too Many Requests",
"status": 429,
"detail": "Rate limit for \"global\" exceeded for tier \"starter_v3\".",
"retry_after_seconds": 12
}
The full mapping below covers every problem-type the API can
return, the matching SDK error class in each language, and
whether isRetryable() (TS / Python) / IsRetryable() (Go)
returns true for that class.
Mapping table
| Problem-type URI | HTTP | TypeScript | Python | Go | Retryable? |
|---|---|---|---|---|---|
errors.driftstack.dev/bad-request |
400 | BadRequestError |
BadRequestError |
BadRequestError |
no |
errors.driftstack.dev/validation-failed |
400 | ValidationError |
ValidationError |
ValidationError |
no |
errors.driftstack.dev/unauthorized |
401 | AuthError |
AuthError |
AuthError |
no |
errors.driftstack.dev/invalid-key |
401 | InvalidKeyError |
InvalidKeyError |
InvalidKeyError |
no |
errors.driftstack.dev/revoked-key |
401 | RevokedKeyError |
RevokedKeyError |
RevokedKeyError |
no |
errors.driftstack.dev/expired-key |
401 | ExpiredKeyError |
ExpiredKeyError |
ExpiredKeyError |
no |
errors.driftstack.dev/forbidden |
403 | ForbiddenError |
ForbiddenError |
ForbiddenError |
no |
errors.driftstack.dev/dashboard-only |
403 | DashboardOnlyError |
DashboardOnlyError |
DashboardOnlyError |
no |
errors.driftstack.dev/not-found |
404 | NotFoundError |
NotFoundError |
NotFoundError |
no |
errors.driftstack.dev/conflict |
409 | ConflictError |
ConflictError |
ConflictError |
no |
errors.driftstack.dev/rate-limited |
429 | RateLimitError |
RateLimitError |
RateLimitError |
yes |
errors.driftstack.dev/concurrency-limit |
429 | ConcurrencyLimitError |
ConcurrencyLimitError |
ConcurrencyLimitError |
no |
errors.driftstack.dev/tier-limit |
429 | TierLimitError |
QuotaExceededError |
QuotaExceededError |
no |
errors.driftstack.dev/session-destroyed |
410 | SessionDestroyedError |
SessionDestroyedError |
SessionDestroyedError |
no |
errors.driftstack.dev/session-timeout |
504 | SessionTimeoutError |
SessionTimeoutError |
SessionTimeoutError |
no |
errors.driftstack.dev/driver-error |
502 | DriverError |
DriverError |
DriverError |
no |
errors.driftstack.dev/driver-not-integrated |
503 | DriverNotIntegratedError |
DriverError |
DriverError |
no |
errors.driftstack.dev/feature-unavailable |
503 | FeatureUnavailableError |
FeatureUnavailableError |
FeatureUnavailableError |
no |
errors.driftstack.dev/byok-anthropic-required |
502 | ByokAnthropicRequiredError |
ByokAnthropicRequiredError |
ByokAnthropicRequiredError |
no |
errors.driftstack.dev/bundled-llm-budget-exhausted |
402 | BundledLlmBudgetExhaustedError |
BundledLlmBudgetExhaustedError |
BundledLlmBudgetExhaustedError |
no |
errors.driftstack.dev/bundled-llm-consent-required |
402 | BundledLlmConsentRequiredError |
BundledLlmConsentRequiredError |
BundledLlmConsentRequiredError |
no |
errors.driftstack.dev/pair-mode-conflict |
409 | PairModeConflictError |
PairModeConflictError |
PairModeConflictError |
no |
errors.driftstack.dev/pair-mode-invalid-transition |
409 | PairModeStateInvalidTransitionError |
PairModeStateInvalidTransitionError |
PairModeStateInvalidTransitionError |
no |
errors.driftstack.dev/storage-quota-exceeded |
409 | StorageQuotaExceededError |
StorageQuotaExceededError |
StorageQuotaExceededError |
no |
errors.driftstack.dev/proxy-validation-failed |
422 | ProxyValidationFailedError |
ProxyValidationFailedError |
ProxyValidationFailedError |
no |
errors.driftstack.dev/proxy-required |
422 | ProxyRequiredError |
ProxyRequiredError |
ProxyRequiredError |
no |
errors.driftstack.dev/upgrade-required |
426 | UpgradeRequiredError |
UpgradeRequiredError |
UpgradeRequiredError |
no |
errors.driftstack.dev/ai-credits-exhausted |
402 | AiCreditsExhaustedError |
AiCreditsExhaustedError |
AiCreditsExhaustedError |
no |
errors.driftstack.dev/trial-ended |
402 | TrialEndedError |
TrialEndedError |
TrialEndedError |
no |
errors.driftstack.dev/profile-in-use |
409 | ProfileInUseError |
ProfileInUseError |
ProfileInUseError |
no |
errors.driftstack.dev/payload-too-large |
413 | PayloadTooLargeError |
PayloadTooLargeError |
PayloadTooLargeError |
no |
errors.driftstack.dev/device-unavailable |
409 | DeviceUnavailableError |
DeviceUnavailableError |
DeviceUnavailableError |
no (1) |
errors.driftstack.dev/session-not-ready |
409 | SessionNotReadyError |
SessionNotReadyError |
SessionNotReadyError |
yes |
errors.driftstack.dev/maintenance |
503 | MaintenanceError |
MaintenanceError |
MaintenanceError |
yes |
errors.driftstack.dev/internal |
5xx | InternalError |
InternalError |
InternalError |
yes |
| (network failure / parse error) | 0 | TransportError |
TransportError |
TransportError |
yes |
(1) Not retried by the SDKs. With code: "archetype_not_served" it is
retryable: true: no machine serves the device right now, and the same
request can succeed in a minute (see below).
errors.driftstack.dev/validation-failed is a 422, not a 400, in two
cases. A SOCKS5 or HTTP proxy host refused when you save or update a proxy
(code proxy_host_not_public or proxy_host_invalid, field
proxy.host; see Proxies). And a direct session create
(POST /v1/sessions through a saved proxy) refused with code
upstream_proxy_not_public (field proxy.host) when the proxy’s address
turns out not to be public at the moment the session starts; more often that
create answers 201 and the session then ends a moment later (see
Sessions). The SDK class is the same.
Two 400 validation-failed refusals name the device (field archetype),
and nothing is created. code device_closed_for_new_profiles: the iPhone
model you named is closed for new profiles. It answers a new profile
(POST /v1/profiles, a copy, an import, a restore from a snapshot) and a
session without a profile, or one that names a device other than its
profile’s. GET /v1/archetypes still lists that model,
because profiles already on it keep it; pick another. code
no_device_open_for_new_profiles: you named no device and no iPhone model on
your plan is open for new profiles. The SDK class is ValidationError.
A 409 device-unavailable with code: "archetype_not_served" and
retryable: true answers a profile or session create on a device no machine
that runs sessions is running right now, and nothing is created.
GET /v1/archetypes/availability
shows it as available_now: false. Choose another device, or send the same
request again in a minute. The SDK class is DeviceUnavailableError; the SDKs
do not retry it for you.
TransportError also ends an agent session’s transcript stream that was
already open (transcript() in TypeScript and Python, Transcript in Go),
so its status can be 200: when the stream passes the SDK’s 8 MiB response
limit, or when the server sends transcript.error and closes the stream
because the reader fell behind (see the
transcript stream). The
SDKs never call it again by themselves: call it again with the last index
you received as lastEventId (last_event_id, LastEventID). Nothing is
lost or repeated.
Raised only in the dashboard
These come from signing in and from account actions that are done in the Driftstack dashboard, so a program using an API key does not receive them. The SDK classes stay, marked deprecated, so code that already catches them keeps compiling.
| Problem-type URI | HTTP | TypeScript | Python | Go | Retryable? |
|---|---|---|---|---|---|
errors.driftstack.dev/invalid-credentials |
401 | InvalidCredentialsError |
InvalidCredentialsError |
InvalidCredentialsError |
no |
errors.driftstack.dev/mfa-step-up-required |
403 | MfaStepUpRequiredError |
MfaStepUpRequiredError |
MfaStepUpRequiredError |
no |
errors.driftstack.dev/email-not-verified |
403 | EmailNotVerifiedError |
EmailNotVerifiedError |
EmailNotVerifiedError |
no |
errors.driftstack.dev/email-already-registered |
409 | EmailAlreadyRegisteredError |
EmailAlreadyRegisteredError |
EmailAlreadyRegisteredError |
no |
errors.driftstack.dev/invalid-auth-token |
400 | InvalidAuthTokenError |
InvalidAuthTokenError |
InvalidAuthTokenError |
no |
errors.driftstack.dev/legal-acceptance-required |
409 | LegalAcceptanceRequiredError |
LegalAcceptanceRequiredError |
LegalAcceptanceRequiredError |
no |
One of these also reaches a program: the OAuth token exchange
(POST /v1/oauth/token) answers legal-acceptance-required (409, with
error: invalid_grant and pending_acceptances) while the account that
approved the app owes acceptance of a legal document; see
OAuth.
When to retry
The SDKs all expose a public isRetryable(err) / is_retryable /
IsRetryable predicate that returns true for the five classes marked
yes in the table above. Use it from your own retry/backoff loop. Retry a create
with the same Idempotency-Key on every attempt, so a create whose
answer was lost returns the session it made instead of starting a second
one (an agent-session create honours the key; POST /v1/sessions does not):
import { randomUUID } from 'node:crypto';
import { Driftstack, isRetryable } from '@driftstack/sdk';
const idempotencyKey = randomUUID();
for (let attempt = 0; attempt < 5; attempt++) {
try {
return await client.agentSessions.create({ mode: 'ai', proxy_id: proxyId }, { idempotencyKey });
} catch (err) {
if (!isRetryable(err)) throw err;
await sleepWithBackoff(attempt, err);
}
}
import uuid
from driftstack import Driftstack, is_retryable, RateLimitError
idempotency_key = str(uuid.uuid4())
for attempt in range(5):
try:
return client.agent_sessions.create(
{"mode": "ai", "proxy_id": proxy_id}, idempotency_key=idempotency_key
)
except Exception as err:
if not is_retryable(err):
raise
wait = err.retry_after_seconds if isinstance(err, RateLimitError) else backoff(attempt)
time.sleep(wait or backoff(attempt))
request := &driftstack.CreateAgentSessionRequest{Mode: "ai", ProxyID: proxyID}
// One key for every attempt.
options := &driftstack.CreateOptions{IdempotencyKey: "create-" + time.Now().Format(time.RFC3339Nano)}
for attempt := 0; attempt < 5; attempt++ {
sess, err := client.AgentSessions.Create(ctx, request, options)
if err == nil {
return sess, nil
}
if !driftstack.IsRetryable(err) {
return nil, err
}
var rl *driftstack.RateLimitError
if errors.As(err, &rl) && rl.RetryAfterSeconds > 0 {
time.Sleep(time.Duration(rl.RetryAfterSeconds) * time.Second)
} else {
time.Sleep(backoff(attempt))
}
}
The built-in retry in each SDK does this automatically — see
the SDK quickstarts for the no-config retry path. Use the
isRetryable predicate when you have your own retry/circuit-
breaker library and want to integrate Driftstack errors.
Why some 5xx aren’t retryable
DriverError (502) and DriverNotIntegrated (503) are
not retryable because the cause is not transient (for
example, the requested device profile is unavailable). Retrying
the same call will not help. Surface the error to the user; the
incidents page will show
any active outage.
FeatureUnavailableError (503) means a feature is not enabled
on this deployment (for example, avatar uploads). Retrying does
not change that; surface the error. One case is different: a session
create refused because no machine could take a new session just then
carries code: "dispatch_no_live_node" (with node_updating: true when the
machines are being updated), retryable: true and a Retry-After header.
Nothing was created; send the same request again after the wait. That 503 is
never about how many sessions you have open: your plan’s concurrent-session
limit is a 429 whose type ends in concurrency-limit. Another is an AI
message answered with code: "ai_provider_unavailable", retry_after_seconds
and (unless streamed) a Retry-After header: the AI that comes with your plan is temporarily
unavailable, so the message was not run and no step ran. Try the AI again
after the wait, with a new Idempotency-Key if you sent one (the key used
keeps this answer); steps sent with POST /v1/agent-sessions/{id}/steps still
work. A turn on your own Anthropic key is never answered this way.
Plan limits (tier-limit)
A 429 tier-limit means the account reached a limit its plan sets.
Waiting does not change it, so it is not retryable. A create past the
saved-profile cap carries limit, current and resource: "profile"
(current counts live profiles; profiles in the trash count toward no
limit). It carries no code: profile_cap_trash is no longer sent, and
trash_count is always 0 for one release and is then removed.
Four refusals also carry a code:
over_plan_limit— the account holds more saved profiles (or proxies) than its plan keeps, and this one is not among the most recently used, up to the plan’s number (on the free trial, only the most recently used profile launches). Nothing is deleted. Launch a recently used one, delete some, or choose a plan with more.trial_session_time_used— the free trial’s 2 hours of session time are used up, so no new session can start. It carriesresource: "session_minutes"andlimit: 120. Choose a plan in the dashboard. We keep your profiles, proxies and settings for 30 days after your trial ends.restore_needs_slot—POST /v1/profiles/{id}/restorewas refused because the account’s live profiles are at the plan’s number. It carriesresource: "profile",limitandcurrent. Delete a profile or choose a plan with more, then restore.restore_cap—POST /v1/profiles/{id}/restorewas refused because the account has used its plan’s restores from the trash for the rolling 30 days. It carriesresource: "profile_restore",limit,current,window_days: 30andnext_restore_at, the time the next restore becomes available. See Restore.
AI agent messages
POST /v1/agent-sessions/{id}/message runs a browser task, so a few of the
rules above read differently there. The
Run AI tasks from your code
guide has the full table.
- The SDKs never retry
message()for you, whateverisRetryablesays: a lost response may belong to a task that already ran. Retry it yourself. - A
502(ByokAnthropicRequiredError) withkey_rejected: truemeans Anthropic refused your own key on the turn’s first planning call. No step ran.key_sourcesays which key (headerorstored) andkey_rejected_reasonsays why, one of three:invalid_or_unauthorized(replace the key; a stored key can be replaced and tested in the dashboard’s Settings),billing(fix billing with Anthropic, also when the account behind the key has no credit left) ororganization_disabled(Anthropic disabled the organization the key belongs to: resolve it with Anthropic, or use a key from another organization). Withoutkey_rejectedthe same type means no key was available at all. A500(InternalError) on a message is on our side, except a refusal from Anthropic we do not recognise yet (for example a spend limit set in the Anthropic Console): if it repeats on your own key, check that account first. RateLimitError(429) on a message is the request rate, or your account already running as many AI turns at once as it may — across your sessions, or on bundled billing.retry_after_secondssays how long to wait. No step ran. On a message,ConcurrencyLimitErrorwithai_tasks_in_flight: truein the body (aiTasksInFlightin the TypeScript SDK; in Python and Go read it from the problem document,e.problem.get("ai_tasks_in_flight")andProblem["ai_tasks_in_flight"] == true, as no other answer has the key) is the same 3-at-once limit: which of the two answers you get depends on how the account’s AI is billed. Wait, then send the message again. Without the flag it is the open-session cap at create.ConflictError(409) withsession_statusmeans the session was not active:"closed", withclosed_reasonsaying why (andclosed_reason_detail, the same reason as one plain sentence you can show), or"paused". No second call is needed to read the reason. When a message or a list of steps is refused because the session’s history could not take it (closed_reason: "transcript-limit"), the 409 also carrieswarningwithcode: "history_full". A full history does not close a session: its oldest entries are dropped and the session goes on. Only a single call that needs more room than the history has after a drop, or a session that has already dropped the most it can, ends this way.session_unresponsiveis not an error status: the message answers200. It is thediagnosis.categoryof a step that was not sent, and thenotice_reasonof the turn that stopped there, when the session stopped answering automated steps — it had already failed to answer one, and a quick check sent just before the step got no answer either. The live view may still show the page; the steps sent to it are what went unanswered. It is never retryable and “continue” will not help: end the session and launch a new one. A turn that hit it before planning anything answersrefusewith arefuse_reasonthat says so and the samenotice_reason,session_unresponsive.FeatureUnavailableError(503) on a message sent with anIdempotency-Key, answered with nocode, means this deployment cannot record keys, so the turn did not run. Sending the same key again will fail the same way, however long you wait; the same message without the header runs the turn, at the cost of its replay protection — see Idempotency. OnPOST /v1/agent-sessions/{id}/stop,stop_unconfirmed: truemeans the stop could not be confirmed just now and IS worth calling again yourself —isRetryablereportsfalsefor this class, so the SDKs will not. A503on Stop withoutstop_unconfirmedmeans AI is not enabled here, and calling again will not help. A503on a message withcode: "ai_provider_unavailable"means the AI that comes with your plan is temporarily unavailable: nothing ran; waitretry_after_secondsand send the message again with a newIdempotency-Key. A turn on your own key never gets it.- With an
Idempotency-Key, a refusal raised before the turn did any work gives the key back — the 409turn_in_progress, the 429s, the 402s, the 403s about the plan’s AI or the model, and the 502 withoutkey_rejected. Fix the cause or wait, then send the same request again with the same key. Every other answer is final for its key and replays; those need a new one. See Idempotency.
Cross-references
- Idempotency keys — when retrying a POST
that creates a resource, send an
Idempotency-Keyheader so the retry replays the original response instead of minting a duplicate. - Pagination — cursor-based pagination contract shared across every list endpoint; request shape, opaque-cursor semantics, and TS/Python/Go drive-to-completion loops.
- API key scopes — when a request returns
forbidden, thedetailnames the required scope. - Rate limits — when a request returns
rate-limited, theRetry-Afterheader carries the wait time. - SDK quickstarts — built-in retry configuration for each language.