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 stillprovisioningholds a slot too, and a create past the cap gets the same429 concurrency-limit. Close agent sessions withDELETE /v1/agent-sessions/{id}in afinallyblock — see Run AI tasks from your code.A session created with
POST /v1/sessionsand aproxy_idcounts 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 withDELETE /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, aretry_after_secondsfield, and aRetry-Afterheader, and the SDKs retry it for you. - Concurrency limits (this page) cap simultaneously-open
sessions. Their 429 carries
type: …/concurrency-limitand noRetry-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/sessionslists your sessions with theirstatus; 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.createdandsession.destroyedentries; 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.