Drıftstack docs
Docs

SDK error handling

Every Driftstack SDK ships a typed error hierarchy mapping application/problem+json responses (RFC 9457) to language-native exceptions. Catch by category for control-flow logic; catch the base type for blanket logging.

The hierarchy is consistent across TypeScript / Python / Go — the same problem-type URIs map to matching errors in every SDK. The table below lists the class name in each language (a couple of names differ per language — see the naming note).

Hierarchy

Server problem-type URIs live under the stable https://errors.driftstack.dev/<slug> host. Dispatch on the slug, not on HTTP status.

Server problem-type slug TS class Python exception Go type Retryable
unauthorized AuthError AuthError *AuthError (ErrAuth) no
invalid-key InvalidKeyError InvalidKeyError *InvalidKeyError (ErrInvalidKey, also ErrAuth) no
expired-key ExpiredKeyError ExpiredKeyError *ExpiredKeyError (ErrExpiredKey, also ErrAuth) no
revoked-key RevokedKeyError RevokedKeyError *RevokedKeyError (ErrRevokedKey, also ErrAuth) no
forbidden ForbiddenError ForbiddenError *ForbiddenError no
dashboard-only DashboardOnlyError DashboardOnlyError *DashboardOnlyError no
validation-failed ValidationError ValidationError *ValidationError no
not-found NotFoundError NotFoundError *NotFoundError no
conflict ConflictError ConflictError *ConflictError no
rate-limited RateLimitError RateLimitError *RateLimitError yes
concurrency-limit ConcurrencyLimitError ConcurrencyLimitError *ConcurrencyLimitError no
tier-limit TierLimitError QuotaExceededError *QuotaExceededError no
driver-not-integrated DriverNotIntegratedError DriverError *DriverError no
session-timeout SessionTimeoutError SessionTimeoutError *SessionTimeoutError no
session-destroyed SessionDestroyedError SessionDestroyedError *SessionDestroyedError no
maintenance MaintenanceError MaintenanceError *MaintenanceError (ErrMaintenance) yes
internal InternalError InternalError *InternalError (ErrInternal) yes
transport (network / parse / timeout) TransportError TransportError *TransportError yes

concurrency-limit has two meanings. At create it is the open-session cap (current_sessions, limit). On an agent-session message with ai_tasks_in_flight: true it is the limit of 3 AI tasks running at once (which of rate-limited and this answer you get depends on how your account’s AI is billed): wait, then send the message again. Do not close sessions for it. TypeScript has the flag as err.aiTasksInFlight; Python and Go read it from the problem body: e.problem.get("ai_tasks_in_flight") and cle.Problem["ai_tasks_in_flight"] == true. Read it that way: every other answer has no such key.

The sign-in and account-action types (legal-acceptance-required, mfa-step-up-required, invalid-credentials and the rest) are raised only in the dashboard, so a program using an API key does not receive them; their SDK classes (LegalAcceptanceRequiredError, MfaStepUpRequiredError and the rest) remain, marked deprecated. One still reaches a program: the OAuth token exchange answers legal-acceptance-required while the account that approved the app owes a legal acceptance (see OAuth). The error reference lists them.

All extend DriftstackError in TypeScript and Python. Go exports no base error type — every typed error embeds an unexported base struct, so match a category with errors.Is against the exported sentinels (ErrAuth, ErrTransport, …) or pull the concrete type with errors.As, as the Go example below shows.

Catching the auth category: the languages differ. In Python InvalidKeyError / ExpiredKeyError / RevokedKeyError subclass AuthError, so except AuthError catches all four. In TypeScript they extend DriftstackError directly — an instanceof AuthError check matches ONLY the unauthorized type, so check the key-specific classes explicitly. In Go, errors.Is(err, ErrAuth) matches the whole category; errors.As(err, &auth *AuthError) does not match the key-specific types.

Naming note: for the tier-limit problem type the TypeScript SDK exports TierLimitError (the original name, shipped since 0.1.x), whereas the Python and Go SDKs name it QuotaExceededError. All three surface the same payload fields (current / limit / recordType, snake_case in Python).

Agent-session (AI) errors

Agent sessions add these typed errors to the table above:

Server problem-type slug TS class Python exception Go type Retryable
ai-credits-exhausted AiCreditsExhaustedError AiCreditsExhaustedError *AiCreditsExhaustedError no
bundled-llm-budget-exhausted BundledLlmBudgetExhaustedError BundledLlmBudgetExhaustedError *BundledLlmBudgetExhaustedError no
bundled-llm-consent-required BundledLlmConsentRequiredError BundledLlmConsentRequiredError *BundledLlmConsentRequiredError no
byok-anthropic-required ByokAnthropicRequiredError ByokAnthropicRequiredError *ByokAnthropicRequiredError no
device-unavailable DeviceUnavailableError DeviceUnavailableError *DeviceUnavailableError no
feature-unavailable FeatureUnavailableError FeatureUnavailableError *FeatureUnavailableError no
payload-too-large PayloadTooLargeError PayloadTooLargeError *PayloadTooLargeError no
profile-in-use ProfileInUseError ProfileInUseError *ProfileInUseError no
proxy-required ProxyRequiredError ProxyRequiredError *ProxyRequiredError no
proxy-validation-failed ProxyValidationFailedError ProxyValidationFailedError *ProxyValidationFailedError no
session-not-ready SessionNotReadyError SessionNotReadyError *SessionNotReadyError yes
storage-quota-exceeded StorageQuotaExceededError StorageQuotaExceededError *StorageQuotaExceededError no
pair-mode-conflict PairModeConflictError PairModeConflictError *PairModeConflictError no
pair-mode-invalid-transition PairModeStateInvalidTransitionError PairModeStateInvalidTransitionError *PairModeStateInvalidTransitionError no

Several agent-session refusals reuse the general classes and carry extra fields: a ConflictError can carry turn_in_progress, session_status or idempotency_status, and a ForbiddenError can carry requires_own_key and model. Read them from err.extensions (TypeScript), err.problem (Python) or Problem (Go). In Python, ForbiddenError subclasses AuthError, so catch ForbiddenError before AuthError if you treat them differently.

message() is never retried for you, whatever the retry predicate says, and with an Idempotency-Key an error response is final for that key. See Run AI tasks from your code for what to do about each one, and Idempotency keys for the requests that accept a key.

TypeScript

import { randomUUID } from 'node:crypto';
import {
  Driftstack,
  DriftstackError,
  AuthError,
  InvalidKeyError,
  ExpiredKeyError,
  RevokedKeyError,
  RateLimitError,
  ConcurrencyLimitError,
  TierLimitError,
  ValidationError,
} from '@driftstack/sdk';

const client = new Driftstack({ apiKey: process.env.DRIFTSTACK_API_KEY! });

try {
  const session = await client.agentSessions.create(
    { mode: 'ai', proxy_id: process.env.DRIFTSTACK_PROXY_ID! },
    { idempotencyKey: randomUUID() },
  );
  // ...
} catch (err) {
  if (
    err instanceof AuthError || // unauthorized
    err instanceof InvalidKeyError ||
    err instanceof ExpiredKeyError ||
    err instanceof RevokedKeyError
  ) {
    // re-mint key + retry, or surface to ops. NOTE: in TS the three
    // bad-key classes do NOT extend AuthError — check them explicitly.
  } else if (err instanceof ConcurrencyLimitError) {
    // At create: too many open sessions. On a message, err.aiTasksInFlight === true
    // means 3 AI tasks are running: wait, send the message again, close nothing.
    console.warn(
      `at the limit: ${err.currentSessions}/${err.limit} (sessions, or AI tasks on a message)`,
    );
  } else if (err instanceof TierLimitError) {
    console.warn(`quota exceeded for ${err.recordType}: ${err.current}/${err.limit}`);
  } else if (err instanceof RateLimitError) {
    await sleep((err.retryAfterSeconds ?? 1) * 1000);
    // retry
  } else if (err instanceof ValidationError) {
    console.error('bad request:', err.message);
  } else if (err instanceof DriftstackError) {
    // catch-all for anything typed Driftstack
  } else {
    throw err; // not a Driftstack error
  }
}

The default retry policy (3 retries, exponential backoff with full jitter, honours Retry-After) handles TransportError, RateLimitError, and InternalError (5xx internal) automatically. Other typed errors propagate immediately so your code can route them.

Python

import os
import uuid

from driftstack import (
    Driftstack,
    AuthError,
    ConcurrencyLimitError,
    QuotaExceededError,
    RateLimitError,
    ValidationError,
    DriftstackError,
)

client = Driftstack(api_key="ds_live_…")

try:
    session = client.agent_sessions.create(
        {"mode": "ai", "proxy_id": os.environ["DRIFTSTACK_PROXY_ID"]},
        idempotency_key=str(uuid.uuid4()),
    )
except AuthError:
    ...
except ConcurrencyLimitError as e:
    # At create: too many open sessions. On a message, e.problem.get("ai_tasks_in_flight")
    # is True when 3 AI tasks are running: wait, send the message again, close nothing.
    print(f"at the limit: {e.current_sessions}/{e.limit} (sessions, or AI tasks on a message)")
except QuotaExceededError as e:
    print(f"quota exceeded for {e.record_type}: {e.current}/{e.limit}")
except RateLimitError as e:
    time.sleep(e.retry_after_seconds or 1)
except ValidationError as e:
    print(f"bad request: {e.message}")
except DriftstackError:
    raise  # catch-all for anything else typed Driftstack

Go

import (
    "errors"
    "time"

    driftstack "github.com/driftstackdev/driftstack-go"
)

client := driftstack.New(os.Getenv("DRIFTSTACK_API_KEY"))
defer client.Close()

session, err := client.AgentSessions.Create(ctx,
    &driftstack.CreateAgentSessionRequest{Mode: "ai", ProxyID: os.Getenv("DRIFTSTACK_PROXY_ID")},
    &driftstack.CreateOptions{IdempotencyKey: "create-" + time.Now().Format(time.RFC3339Nano)})
if err != nil {
    var auth *driftstack.AuthError
    if errors.As(err, &auth) {
        // matches ONLY the `unauthorized` problem type — for the whole
        // auth category (incl. invalid/expired/revoked key) use the
        // errors.Is(err, driftstack.ErrAuth) sentinel below.
    }

    var rl *driftstack.RateLimitError
    if errors.As(err, &rl) {
        time.Sleep(time.Duration(rl.RetryAfterSeconds) * time.Second)
        // retry
    }

    var cle *driftstack.ConcurrencyLimitError
    if errors.As(err, &cle) {
        // At create: too many open sessions. On a message, cle.Problem["ai_tasks_in_flight"] == true
        // means 3 AI tasks are running: wait, send the message again, close nothing.
        log.Printf("at the limit: %d/%d (sessions, or AI tasks on a message)", cle.CurrentSessions, cle.Limit)
    }

    if errors.Is(err, driftstack.ErrAuth) {
        // sentinel-style match for AuthError category
    }

    log.Fatal(err)
}

errors.As for the structured payload, errors.Is for category matching against the package-level sentinels (ErrAuth, ErrTransport, etc.).

Retry policy details

Retryable errors by default:

  • TransportError (network / timeout / response parse failure).
  • RateLimitError (HTTP 429). Honours the server’s retry-after-seconds value when present.
  • InternalError (the 5xx internal problem type).

Non-retryable: every other typed error. Auth errors aren’t retried because retrying with the same bad key won’t help. Validation errors aren’t retried because the client request is wrong. Concurrency / quota errors aren’t retried because retrying without freeing capacity will keep failing. The other 5xx-status typed errors (DriverError 502, DriverNotIntegratedError 503, SessionTimeoutError 504) are treated as terminal and are NOT auto-retried.

Override via constructor options:

TypeScript:

const client = new Driftstack({
  apiKey,
  retry: {
    maxAttempts: 5,
    initialDelayMs: 500,
    maxDelayMs: 10_000,
    // Backoff multiplier is fixed at 2× in the TS SDK (it is not a config
    // field; Python and Go expose it as backoff_multiplier / BackoffMultiplier).
  },
});

Python:

from driftstack.retry import RetryConfig

client = Driftstack(
    api_key="ds_live_…",
    retry=RetryConfig(max_retries=5, initial_delay_ms=500, max_delay_ms=10_000),
)

# disable entirely (predictable testing):
client = Driftstack(api_key="…", retry=RetryConfig(enabled=False))

Go:

client := driftstack.New(
    apiKey,
    driftstack.WithRetry(driftstack.RetryConfig{
        MaxRetries:        5,
        InitialDelay:      500 * time.Millisecond,
        MaxDelay:          10 * time.Second,
        BackoffMultiplier: 2.0,
    }),
)
// disable entirely:
client = driftstack.New(apiKey, driftstack.WithRetry(driftstack.RetryConfig{Disabled: true}))

Cancellation

All three SDKs honour cancellation:

  • TypeScript: pass an AbortSignal via per-call options ({ signal }). Aborting between retry attempts terminates the retry loop; aborting mid-request cancels the in-flight fetch.
  • Python: the sync client uses thread-blocking sleeps; the async client uses asyncio.sleep and respects asyncio.CancelledError.
  • Go: pass a context.Context first arg to every method. Cancelling the context aborts both the retry loop + the in-flight http.Request.

Surfacing errors to your end users

The message field on every DriftstackError is human-readable but technical. For customer-facing surfaces, map the error to a user-friendly message:

function userMessageFor(err: unknown): string {
  if (err instanceof AuthError) return 'Your session expired — please sign in again.';
  if (err instanceof ConcurrencyLimitError) {
    if (err.aiTasksInFlight === true) {
      return `${err.limit} AI tasks are already running. Yours starts when one of them finishes: nothing needs to be stopped.`;
    }
    return `You're at the maximum number of concurrent sessions (${err.limit}). Stop one you started before starting a new one.`;
  }
  if (err instanceof RateLimitError) return "We're going too fast — give it a moment.";
  if (err instanceof DriftstackError) return 'Something went wrong on our end.';
  return 'Unexpected error.';
}

Don’t expose raw DriftstackError.message in customer UIs.

See also

  • SDK installation — install + configure each SDK.
  • Quickstart — first session in five minutes.
  • Webhook events — server-pushed events use a separate signature-verification path; not error-handling.