# Configuration & auth

> Environment variables, client options, API key permissions, worker identity, timeouts and transport retries for the TypeScript and Python SDKs.

Source: https://docs.getrunstate.com/configuration/

## Environment variables

Both SDKs read the same three variables. Explicit constructor options take precedence.

| Variable | Required | Default | Purpose |
| --- | --- | --- | --- |
| `RUNSTATE_API_KEY` | yes | none | API key, sent as `Authorization: Bearer <key>` on every request. |
| `RUNSTATE_SPACE_ID` | yes | none | The space (project or environment) every call is bound to. |
| `RUNSTATE_BASE_URL` | no | `http://localhost:8080` | API origin. Set it to `https://api.getrunstate.com` for the hosted service. |

If the API key or space id is missing, constructing the client throws `ConfigError` immediately, before any network call. There is no environment variable for the holder name; pass it as an option.

> **Caution**
>
> The default base URL is `http://localhost:8080`. Always set `RUNSTATE_BASE_URL` (or the `baseURL` / `base_url` option) when you use the hosted service.

## Creating a client

**TypeScript**

```ts
import { Runstate } from 'runstate-sdk';

const rs = new Runstate({
  baseURL: 'https://api.getrunstate.com',
  apiKey: process.env.RUNSTATE_API_KEY,
  spaceId: process.env.RUNSTATE_SPACE_ID,
  holder: 'research-worker-1', // stable name for this logical worker
  requestTimeoutMs: 10_000, // per attempt
  retries: 2, // transport retries after the first attempt
});
```

**Python**

```python
import os

from runstate import AsyncRunstate

rs = AsyncRunstate(
    base_url="https://api.getrunstate.com",
    api_key=os.environ["RUNSTATE_API_KEY"],
    space_id=os.environ["RUNSTATE_SPACE_ID"],
    holder="research-worker-1",  # stable name for this logical worker
    request_timeout_ms=10_000,  # per attempt
    retries=2,  # transport retries after the first attempt
)
```

Python has two clients with the same object graph: `AsyncRunstate` (async, the primary client) and `Runstate` (blocking; runs the async client on a dedicated background thread). See the [Python SDK reference](https://docs.getrunstate.com/sdk/python/#blocking-client).

| Option (TS / Python) | Default | Notes |
| --- | --- | --- |
| `apiKey` / `api_key` | `RUNSTATE_API_KEY` | Required. |
| `spaceId` / `space_id` | `RUNSTATE_SPACE_ID` | Required. |
| `baseURL` / `base_url` | `RUNSTATE_BASE_URL`, else `http://localhost:8080` | Trailing slashes are removed. |
| `holder` / `holder` | random `worker-xxxxxx` | Logical worker name, stable across restarts. |
| `requestTimeoutMs` / `request_timeout_ms` | `10000` | Deadline for **each attempt**, not the whole call. |
| `retries` / `retries` | `2` | Extra attempts after a transport failure. |
| `fetchImpl` (TS only) | global `fetch` | Test seam. |

Constructing a client makes no network calls. The TypeScript client holds no connections, and `rs.close()` exists only for symmetry. In Python, close the client when you're done: `await rs.aclose()` or `async with AsyncRunstate(...) as rs:` (blocking client: `rs.close()` or `with Runstate(...) as rs:`).

To use several spaces from one process, `rs.withSpace(spaceId)` (Python: `rs.with_space(space_id)`) returns a client bound to another space that shares the configuration and identity.

## API keys and permissions

Create and revoke keys in the console under **API keys** in a space. The secret is shown once and never stored in readable form. Organization, space and key management is console-only today; there is no public API or SDK method for it.

Each key has a set of permissions:

| Permission | Allows |
| --- | --- |
| `coordination_read` | Every `GET` request: task, run, group and budget status, lists, events, watch, diagnostics. |
| `coordination_write` | Non-`GET` requests that don't need `resource_config`: runs, claims, queues, messages, tasks, admission, pools, task groups, barriers, timers. |
| `resource_config` | `PUT` requests, and every non-`GET` request under `/allowances`, `/budgets`, `/work-limits` and `/limits`. |
| `usage_read` | Reserved for usage data. The coordination API doesn't check it today; `GET /usage` needs `coordination_read` like every other read. |

> **Caution: Quota and budget calls need `resource_config`**
>
> The permission check is by path, so taking units from a shared quota (`quota.take()`), reserving or settling budget, and creating quotas, budgets and work limits all need `resource_config`, not only `coordination_write`. A worker key without it gets `FORBIDDEN` from those calls.

A key can also be limited to specific spaces; using it for another space returns `FORBIDDEN`. An id from another organization returns `NOT_FOUND`, so resource ids don't leak between tenants.

## Worker identity

Every claim, delivery and pool lease records a **holder** and a **session**:

- `holder` names the logical worker. Keep it stable across restarts (for example `crawler-3` or the pod name) so the console and diagnostics show who owns what.
- The session is generated fresh every time you construct a client. Two live processes never present the same holder and session pair, even if you give them the same holder name.

## Timeouts, retries and idempotency

- **Per-attempt timeout.** `requestTimeoutMs` applies to each HTTP attempt. With the defaults (10 s, 2 retries) a call to an unreachable API can take about 30 seconds before it throws `UnavailableError`.
- **Transport retries only.** The SDKs retry network failures and timeouts, with 25 to 100 ms of jitter. They never retry a well-formed error response such as `CLAIM_HELD` or `INSUFFICIENT_BUDGET`; those surface as typed errors immediately. Waiting helpers like `claim.acquire({ wait: true })` handle specific codes on top of this, see [Errors & retries](https://docs.getrunstate.com/errors/#what-the-sdks-retry-for-you).
- **Idempotency keys.** Every non-`GET` request carries a fresh `Idempotency-Key` header, reused across the transport retries of that one call, so a retried request is never applied twice. Separate calls get separate keys: calling `rs.scopes.create()` twice creates two runs.
- **Client-side waits end locally.** Methods like `ticket.result()`, `group.wait()` and `timer.wait()` poll until their `timeoutMs` and then throw `WaitTimeoutError`. The task, group or timer keeps going on the server.
