Drıftstack docs
Docs

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 carries resource: "session_minutes" and limit: 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}/restore was refused because the account’s live profiles are at the plan’s number. It carries resource: "profile", limit and current. Delete a profile or choose a plan with more, then restore.
  • restore_cap — POST /v1/profiles/{id}/restore was refused because the account has used its plan’s restores from the trash for the rolling 30 days. It carries resource: "profile_restore", limit, current, window_days: 30 and next_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, whatever isRetryable says: a lost response may belong to a task that already ran. Retry it yourself.
  • A 502 (ByokAnthropicRequiredError) with key_rejected: true means Anthropic refused your own key on the turn’s first planning call. No step ran. key_source says which key (header or stored) and key_rejected_reason says 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) or organization_disabled (Anthropic disabled the organization the key belongs to: resolve it with Anthropic, or use a key from another organization). Without key_rejected the same type means no key was available at all. A 500 (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_seconds says how long to wait. No step ran. On a message, ConcurrencyLimitError with ai_tasks_in_flight: true in the body (aiTasksInFlight in the TypeScript SDK; in Python and Go read it from the problem document, e.problem.get("ai_tasks_in_flight") and Problem["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) with session_status means the session was not active: "closed", with closed_reason saying why (and closed_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 carries warning with code: "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_unresponsive is not an error status: the message answers 200. It is the diagnosis.category of a step that was not sent, and the notice_reason of 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 answers refuse with a refuse_reason that says so and the same notice_reason, session_unresponsive.
  • FeatureUnavailableError (503) on a message sent with an Idempotency-Key, answered with no code, 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. On POST /v1/agent-sessions/{id}/stop, stop_unconfirmed: true means the stop could not be confirmed just now and IS worth calling again yourself — isRetryable reports false for this class, so the SDKs will not. A 503 on Stop without stop_unconfirmed means AI is not enabled here, and calling again will not help. A 503 on a message with code: "ai_provider_unavailable" means the AI that comes with your plan is temporarily unavailable: nothing ran; wait retry_after_seconds and send the message again with a new Idempotency-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 409 turn_in_progress, the 429s, the 402s, the 403s about the plan’s AI or the model, and the 502 without key_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-Key header 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, the detail names the required scope.
  • Rate limits — when a request returns rate-limited, the Retry-After header carries the wait time.
  • SDK quickstarts — built-in retry configuration for each language.