Skip to content

HTTP API

vxnsin edited this page Sep 2, 2026 · 8 revisions

HTTP API

Base URL http://127.0.0.1:7010. Interactive documentation at /docs, generated from the same definitions the server uses, so it cannot drift.

Endpoints

Method Path Token Purpose
GET /health none Liveness, and which warden this is
GET /v1/pool either Pool size, allocated, free, reserved
GET /v1/services either List registrations, filter by project and kind
POST /v1/services API Register a service
GET /v1/services/{name} either Look one up
POST /v1/services/{name}/heartbeat API Extend a lease
DELETE /v1/services/{name} API Release a port
GET /v1/listeners either Every socket bound on that machine
DELETE /v1/listeners/{pid} API Stop a process, off unless WARDEN_ALLOW_KILL
POST /v1/nodes cluster A warden announces itself
GET /v1/nodes either Every warden this one knows
DELETE /v1/nodes/{name} API Forget a warden
GET /v1/fleet/services either Everything the fleet holds, plus what did not answer
GET /v1/fleet/services/{node}/{name} either One service on one named node
GET /v1/update either Whether a newer warden exists
POST /v1/update either Ask this warden to update itself
POST /v1/fleet/update API Ask every warden to update itself
GET /v1/fleet/pool either How much of its pool every node has left
GET /v1/fleet/listeners either Every socket bound anywhere in the fleet
POST /v1/fleet/services/{node} API Register on one named node
POST /v1/fleet/services/{node}/{name}/heartbeat API Extend a lease there
DELETE /v1/fleet/services/{node}/{name} API Release a port there
DELETE /v1/fleet/listeners/{node}/{pid} API Stop a process there

"API" means WARDEN_TOKEN, "cluster" means WARDEN_CLUSTER_TOKEN, "either" accepts both. A check is skipped entirely when the corresponding setting is empty.

The cluster token opens announcing and reading, and nothing that changes state — which is why forwarding a write to a node takes WARDEN_TOKEN and carries the caller's own authorization on to that node. See Cluster.

Authorization: Bearer <token>

Registering

curl -s localhost:7010/v1/services \
  -H 'content-type: application/json' \
  -d '{"name": "shop-api", "kind": "backend", "project": "shop"}'
{
  "name": "shop-api",
  "kind": "backend",
  "project": "shop",
  "host": "127.0.0.1",
  "port": 8000,
  "pid": null,
  "meta": {},
  "ttl": null,
  "created_at": "2026-08-31T12:00:00Z",
  "updated_at": "2026-08-31T12:00:00Z",
  "expires_at": null
}

201 when the name is new, 200 when an existing registration was renewed. That difference is the only way to tell a first start from a restart.

Fields you may send:

Field Meaning
name Required. Unique on this warden
kind Required. What the service is
project Groups services of one codebase
host Interface the service will bind to, default 127.0.0.1
preferred_port Wish for this port, fall back to the pool
require_port Insist on this port, 409 if it is not free
pid Process id, shown in listings
ttl Seconds until the registration expires
meta Free-form string map, stored and returned untouched

Unknown fields are rejected with 422 rather than ignored, so a typo in prefered_port is a loud failure instead of a silently different port.

How a port is chosen

  1. A registration that already exists keeps its port, unless another registration has taken it meanwhile.
  2. require_port is granted if free, refused with 409 if not.
  3. preferred_port is granted if free, and otherwise quietly gives way to the pool.
  4. Otherwise the lowest free port in the pool wins.
  5. Before a fresh port is handed out it is tested for an existing listener, so anything started outside the registry is skipped. A service keeping its own port is not probed, since it may still be bound to it.

Ports are tracked per host, so 10.0.0.5:8000 and 127.0.0.1:8000 are two different endpoints and may be held by two different services.

Leases

ttl makes a registration expire. POST /v1/services/{name}/heartbeat pushes the expiry out again:

curl -X POST localhost:7010/v1/services/ci-runner/heartbeat \
  -H 'content-type: application/json' -d '{"ttl": 600}'

Sent without a ttl, the heartbeat renews the lease the service registered with. It can never turn a lease into a permanent registration by accident.

Expired registrations are dropped on the next request that touches the registry, not by a timer.

Announcing a node

curl -s http://hub:7010/v1/nodes \
  -H 'content-type: application/json' \
  -H 'Authorization: Bearer <cluster token>' \
  -d '{"name": "build-01", "url": "http://build-01:7010",
       "pool_start": 9000, "pool_end": 9099, "version": "0.1.0"}'
{
  "name": "build-01",
  "url": "http://build-01:7010",
  "pool_start": 9000,
  "pool_end": 9099,
  "version": "0.1.0",
  "first_seen": "2026-08-31T12:00:00Z",
  "last_seen": "2026-08-31T12:04:00Z",
  "expires_at": "2026-08-31T12:05:30Z",
  "status": "online"
}

status is computed as you ask, not stored, so it is never out of date.

You do not normally call this by hand: a warden with WARDEN_UPSTREAM set does it for you, three times per lease.

Health

{
  "status": "ok",
  "version": "0.1.0",
  "node": "build-01",
  "role": "edge",
  "services": 2,
  "nodes": 0
}

role is edge when this warden reports to another, hub when it does not. /health never requires a token, so it works as a container or load-balancer probe.

Failures

Every refusal comes back as {"detail": "..."} with a status that says what kind of problem it is:

Status Means
401 Missing or wrong token
403 Allowed to ask, not allowed to do — stopping processes with WARDEN_ALLOW_KILL off, or a process this account may not touch
404 No such service, node or process
409 A required port is held by someone else, or a process ignored the request to stop
422 The request body does not hold up: a bad name, an unknown field, both port wishes at once
503 The pool has no free port left

The detail is written to be shown to a person as-is:

{"detail": "port 8000 is held by 'shop-api'"}

The Python client

Everything above is wrapped, with the same errors as exceptions:

from warden import WardenClient

with WardenClient("http://hub:7010", token="...") as client:
    for node in client.nodes():
        print(node.name, node.status)
Method Does
register(name, kind=..., node=...) Claim a port, optionally on another node
lookup(name) One registration
services(project=..., kind=...) All of them
heartbeat(name, ttl=..., node=...) Extend a lease
release(name, node=...) Give a port back
pool() Pool usage
listeners(udp=...) Sockets on that machine
stop(pid, force=..., node=...) End a process
nodes() The fleet
announce(name, url=..., ...) Report a node
forget(name) Remove a node
fleet_services(project=..., kind=...) Everything the fleet holds
fleet_lookup(node, name) One service on one named node
fleet_pool() Every node's pool, and the totals
fleet_listeners(udp=...) Every socket bound anywhere
session(name, ...) Context manager that releases on exit

Exceptions all descend from WardenError: UnknownServiceError, UnknownNodeError, PortUnavailableError, PoolExhaustedError, NotPermittedError, ProtectedProcessError, StillRunningError, UnknownProcessError. A warden that cannot be reached raises the base WardenError with an explanation, not a bare connection error.

Clone this wiki locally