Drıftstack docs
Docs

Concurrency & backpressure

Driftstack caps how many sessions an account can keep open at the same time — that’s your concurrency cap, and it’s the thing you pay for (capacity, not per-call charges). When a create would push you past it, the API answers with a 429 — that refusal is backpressure: the server’s signal to slow down and try again later. This page covers the cap per plan, the exact 429 you’ll see, and the clean way to back off in each SDK.

Concurrent-session caps

These are the caps the API enforces:

Plan Tier id Cap Notes
Trial trial 1 The free trial.
Starter starter_v3 1
Pro pro_v3 3
Team team_v3 6
Scale scale_v3 12
Enterprise enterprise 20+ Set by contract.

The earlier plans, no longer offered, keep their caps for the accounts that hold them:

Plan Tier id Cap
Personal solo_manual 1
Team team_manual 3
Agency agency_manual 8
API Starter api_starter 2
API Builder api_builder 8
API Scale api_scale 24

A session counts against the cap from the moment it’s created until it ends: until you destroy it, or it reaches a terminal state on its own (destroyed or errored; an agent session, closed). A session also ends on its own (after a time without activity, or at its time limit) or when the machine running it loses it, and its slot is free as soon as it has ended: no DELETE is needed for that. Cleanup is still your responsibility: a leaked session that is still running holds its slot until it ends, and on a paid plan that can take hours (a session that no AI agent drives runs for up to 4 hours; no session stays open past about 12 hours from when it was created). (On the free trial a session is closed for you after 20 minutes, so a leaked one frees its slot — but it counts against the trial’s 2 hours of session time while it runs.) Wrap session work in a try / finally (or defer in Go) so the destroy always runs — the session lifecycle guide shows the pattern.

Agent sessions have a cap of their own. Open agent sessions (POST /v1/agent-sessions) are limited to the same per-tier number, counted separately from the sessions above. A session that is still provisioning holds a slot too, and a create past the cap gets the same 429 concurrency-limit. Close agent sessions with DELETE /v1/agent-sessions/{id} in a finally block — see Run AI tasks from your code.

A session created with POST /v1/sessions and a proxy_id counts on both caps while it runs: it holds a slot here, and it runs on the same machines as agent sessions, which track it as an open agent session too. Destroying it with DELETE /v1/sessions/{id} frees both.

The 429 signal

When POST /v1/agent-sessions (or POST /v1/sessions) would exceed the cap, the server responds with an RFC 9457 problem body:

{
  "type": "https://errors.driftstack.dev/concurrency-limit",
  "title": "Concurrent session limit reached",
  "status": 429,
  "detail": "Account already has 2 active sessions; tier permits 2.",
  "current_sessions": 2,
  "limit": 2
}

Dispatch on the type URI (or the SDK’s typed error class), never on the title / detail strings — those can be reworded; the type URI is stable.

A leftover session is the usual cause. An open session counts against the cap until it ends, whether or not anything is running on it (an agent session counts while its status is provisioning or active), so on a plan whose cap is 1 a session an earlier run left open — a test that crashed before its cleanup, say — makes the next create answer this 429. It is not a fault in what you are running: list your sessions (GET /v1/sessions, and GET /v1/agent-sessions for agent sessions), end the ones you created and no longer need (DELETE /v1/sessions/{id}, DELETE /v1/agent-sessions/{id}), and create again. POST /v1/agent-sessions answers the same problem body.

Note there is no Retry-After header on this 429 — that header appears on rate-limit 429s, where waiting a fixed time genuinely helps. A concurrency 429 clears only when one of your own sessions finishes, so the right reaction is to destroy the sessions you created, or wait for sessions to end, then retry with backoff.

One exception, on an agent-session message: the same type with ai_tasks_in_flight: true is not about open sessions. It means 3 AI tasks are already running, and it clears by itself when one finishes: wait, then send the message again, and close nothing. See the error reference.

Backing off in each SDK

The SDKs retry rate-limit and transport errors automatically, but deliberately do not retry concurrency-limit — retrying without freeing a slot can’t succeed, so the error is surfaced to your code to decide. A simple exponential backoff on an agent-session create (the session to use on hosted Driftstack, where every session needs the proxy_id of one of your saved proxies). Send the same Idempotency-Key on every attempt: a refused create stores nothing under it, and a create whose answer was lost returns the session it made instead of starting a second one. Full rules: Idempotency keys.

TypeScript:

import { randomUUID } from 'node:crypto';
import { Driftstack, ConcurrencyLimitError } from '@driftstack/sdk';

async function createWithBackoff(client: Driftstack, proxyId: string) {
  const idempotencyKey = randomUUID();
  let delayMs = 1_000;
  for (let attempt = 0; attempt < 5; attempt++) {
    try {
      return await client.agentSessions.create(
        { mode: 'ai', proxy_id: proxyId },
        { idempotencyKey },
      );
    } catch (err) {
      if (!(err instanceof ConcurrencyLimitError)) throw err;
      console.warn(`at ceiling: ${err.currentSessions}/${err.limit}`);
      await new Promise((r) => setTimeout(r, delayMs));
      delayMs = Math.min(delayMs * 2, 60_000);
    }
  }
  throw new Error('no session slot freed up after 5 attempts');
}

Python:

import time
import uuid
from driftstack import Driftstack, ConcurrencyLimitError

def create_with_backoff(client: Driftstack, proxy_id: str):
    idempotency_key = str(uuid.uuid4())
    delay_s = 1.0
    for _ in range(5):
        try:
            return client.agent_sessions.create(
                {"mode": "ai", "proxy_id": proxy_id}, idempotency_key=idempotency_key
            )
        except ConcurrencyLimitError as e:
            print(f"at ceiling: {e.current_sessions}/{e.limit}")
            time.sleep(delay_s)
            delay_s = min(delay_s * 2, 60.0)
    raise RuntimeError("no session slot freed up after 5 attempts")

Go:

func createWithBackoff(ctx context.Context, client *driftstack.Client, proxyID, idempotencyKey string) (*driftstack.AgentSession, error) {
    delay := time.Second
    for attempt := 0; attempt < 5; attempt++ {
        session, err := client.AgentSessions.Create(ctx,
            &driftstack.CreateAgentSessionRequest{Mode: "ai", ProxyID: proxyID},
            &driftstack.CreateOptions{IdempotencyKey: idempotencyKey})
        if err == nil {
            return session, nil
        }
        var cle *driftstack.ConcurrencyLimitError
        if !errors.As(err, &cle) {
            return nil, err
        }
        log.Printf("at ceiling: %d/%d", cle.CurrentSessions, cle.Limit)
        time.Sleep(delay)
        if delay *= 2; delay > time.Minute {
            delay = time.Minute
        }
    }
    return nil, fmt.Errorf("no session slot freed up after 5 attempts")
}

Better than a blind timer: if your workers run in one process, have the finishing worker’s close unblock the next create directly — you’re reacting to the actual slot release instead of guessing.

Don’t pre-emptively throttle yourself below your cap, either. The server’s count is the truth; back off when it tells you to.

Pooling sessions vs creating ephemeral ones

Two patterns work well:

  • Ephemeral (default): create a session, do the work, destroy it. Simple, and there’s nothing to leak as long as the destroy is in a finally.
  • Pooled: keep N sessions open and round-robin work onto them. Saves the per-job create round-trip for bursty workloads, but you now own pool health: a session ends on its own at 4 hours when no AI agent drives it, and about 12 hours after it was created at the latest otherwise. It ends sooner if it sits unused: after about 5 minutes for an agent session with no step running and no input, and after 30 minutes for a session that no AI agent drives. So replace any session that has ended or hit errored, and destroy the pool on shutdown.

If you pool, don’t subscribe the whole cap: leave a slot or two free for ad-hoc creates that shouldn’t have to evict a pooled session.

Rate limits vs concurrency limits

These are separate systems that both answer 429:

  • Rate limits cap request throughput (token buckets — how many calls per second). Their 429 carries type: …/rate-limited, a retry_after_seconds field, and a Retry-After header, and the SDKs retry it for you.
  • Concurrency limits (this page) cap simultaneously-open sessions. Their 429 carries type: …/concurrency-limit and no Retry-After, and the SDKs hand it to your code.

You can hit either one independently — a POST /v1/sessions draws from the sessions:create rate bucket (never global; each call uses exactly one bucket key) and checks the concurrency cap.

Raising the cap

Upgrading your tier raises the cap — see driftstack.io/pricing. Above the Enterprise floor, per-account overrides are possible: email [email protected] with the workload shape.

Watching your own concurrency

  • GET /v1/sessions lists your sessions with their status; the active ones count against your plan’s concurrent-session cap. The dashboard shows the live count next to the cap.
  • The account’s audit log, which you read in the Driftstack dashboard, records session.created and session.destroyed entries; diffing the two gives you an open-sessions timeseries for capacity planning.

Next steps

  • Session lifecycle — states, teardown, and the always-destroy pattern.
  • Rate limits — the other 429, with per-tier bucket tables.
  • Errors — every problem type and which ones are retryable.