Skip to content

HTTP API

vxnsin edited this page Sep 4, 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 /metrics either Prometheus metrics
GET /v1/pool either Pool size, allocated, free, reserved
GET /v1/services either List registrations, filter by project and kind
GET /v1/history either What happened, filter by port and name
GET /v1/events either What is happening, as server-sent events
GET /v1/webhook either Where events are posted, and whether they arrive
POST /v1/services API Register a service
POST /v1/groups API Register several ports for one thing, all or none
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.

Holders

GET /v1/services?holders=true adds a holder to every registration: running or gone, with a holder_reason when it is gone.

{
  "name": "old-job",
  "port": 8001,
  "pid": 9930,
  "holder": "gone",
  "holder_reason": "nothing is on 8001 and pid 9930 is gone"
}

A holder is gone when the process it named no longer exists, or when nothing is listening on its port. Working that out costs one sweep of the machine's sockets, which is why it is asked for rather than always done, and it is only ever right on the machine the services run on. A registration touched in the last 30 seconds is never called gone.

History

curl -s 'localhost:7010/v1/history?port=8000&limit=5'
[
  {
    "at": "2026-08-31T09:12:44.301Z",
    "action": "released",
    "name": "shop-api",
    "kind": "backend",
    "project": "shop",
    "host": "127.0.0.1",
    "port": 8000,
    "pid": 14204
  }
]

action is one of registered, renewed, moved, released or expired. Newest first. limit defaults to 100 and is refused above 1000. Rows are dropped past a cap as new ones are written.

Events

GET /v1/events holds the connection open and writes server-sent events until the caller hangs up. A comment line arrives immediately, so a client knows it is connected before anything has happened, and another every twenty seconds while nothing does.

: watching

event: registered
data: {"at":"2026-03-01T09:30:00Z","action":"registered","name":"shop-api", ...}

The event name is the action: registered, renewed, moved, released or expired. The data is the same shape GET /v1/history returns for one row. Every action reaches the stream; filtering is the reader's business.

GET /v1/webhook says where this warden posts events and how that has gone:

{
  "configured": true,
  "target": "https://discord.com/...",
  "format": "discord",
  "actions": ["expired", "moved", "registered", "released"],
  "watching": 1,
  "delivered": 7,
  "failed": 0,
  "dropped": 0,
  "last_error": null,
  "last_sent": "2026-03-01T09:30:00Z"
}

The address is cut back to its host on purpose: a webhook URL is a credential, and its path is the half worth stealing. See Events and webhooks.

Groups

POST /v1/groups hands out several ports for one thing, under one transaction.

{ "name": "stack", "kind": "backend", "count": 4, "contiguous": false }

201 and a list of registrations named stack-1 to stack-4, or 503 and nothing written at all. count is 1 to 64. There is no require_port: that has no sensible answer for four ports at once.

Metrics

GET /metrics in the Prometheus text format:

warden_info{version="0.1.0",node="hub",role="hub"} 1
warden_pool_ports 1000
warden_pool_allocated 2
warden_pool_available 997
warden_pool_reserved 1
warden_services{kind="backend"} 1
warden_services{kind="frontend"} 1
warden_nodes{status="online"} 2

Behind the same token as every other read. Left open on a warden bound to 0.0.0.0 it would hand out the shape of the whole fleet to anyone who asked, so a scraper carries the token like everything else:

scrape_configs:
  - job_name: warden
    authorization:
      credentials: your-warden-token
    static_configs:
      - targets: ["hub:7010"]

Nothing in it costs a syscall. A scrape every fifteen seconds that swept every socket on the machine would cost more than the numbers are worth, which is why gone holders are not in here — warden ls --holders asks for those when a person wants them.

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. Full usage on the Python client page; the short version:

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