Library.
A distributed semaphore over HTTP. Register a pool of count numbered slots
under a secret UUID, then borrow one for a bounded time, optionally
waiting for a slot to free up — each borrow hands you a slot position in
0 … count − 1. Return a lease early by its UUID, or let it
expire on its own. Whoever holds the resource UUID may borrow — the UUID is the only
credential.
Use cases
- Concurrency limits. Cap how many workers hit a fragile upstream, a rate limited API, or a license seat at once — without running your own coordination service.
-
Mutual exclusion. Register a pool of
1to get a lease-based lock with an automatic timeout, so a crashed holder never wedges the resource forever. - Pools of scarce things. Hand out a bounded set of test accounts, device slots, GPU shares, or staging environments to many clients, first come first served.
- Backpressure. Let clients block for a bounded window when the pool is exhausted, instead of polling, and wake the instant a permit returns.
Model
A resource is a pool of count numbered slots, identified by a
UUID you choose. The slots are the integers 0 … count − 1. A
lease is one held slot, identified by its own server-generated UUID, with
an expiry; borrowing hands you the lowest free slot's position. At any instant
the pool has in_use active (unexpired, unreturned) leases and
available = count − in_use free slots.
-
Positions are stable and dense. With
count = 4a borrow returns one of0, 1, 2, 3— always the lowest that is free — and a returned or expired slot is the next one handed back out. - The resource UUID is a secret. Anyone who has it can inspect, borrow, register, or delete the pool, so treat it like a bearer token. Pick an unguessable random UUID — a v4 UUID has 122 bits of entropy.
-
Borrow takes a permit for up to
ttlseconds. If the pool is exhausted, the call waits up towaitseconds and returns the first permit that frees up; if none frees up in time it fails with409. -
A lease is freed by an explicit return or automatically when its
ttlelapses, whichever comes first. There is no renewal: borrow again for a longer hold. Loweringcountnever revokes outstanding leases; it only withholds new ones until enough free up. - All IDs are UUIDs. All bodies and responses are JSON. There are no auth headers — the URL's resource UUID is the credential.
Resources
Register a pool
PUT /l/<id> registers the pool at the UUID you choose with a
count. It is idempotent: the same call on an existing pool updates its
count, so this is also how you grow or shrink the pool. Raising
count can immediately satisfy waiting borrowers; lowering it below
in_use does not recall live leases — it just blocks new borrows until enough
expire or return. count must be between 0 and 1000.
PUT /l/9f1c2e7a-5b3d-4c8e-a1f0-6d2b9c4e7a01
{
"count": 4
}
{
"id": "9f1c2e7a-5b3d-4c8e-a1f0-6d2b9c4e7a01",
"count": 4,
"in_use": 0,
"available": 4
}
Inspect
GET /l/<id> returns the current pool state. It does not reveal lease UUIDs —
those are separate secrets held by borrowers.
GET /l/9f1c2e7a-5b3d-4c8e-a1f0-6d2b9c4e7a01
{
"id": "9f1c2e7a-5b3d-4c8e-a1f0-6d2b9c4e7a01",
"count": 4,
"in_use": 2,
"available": 2
}
Delete
DELETE /l/<id> removes the pool and all of its leases. Outstanding lease
UUIDs stop being valid. The call is idempotent.
DELETE /l/9f1c2e7a-5b3d-4c8e-a1f0-6d2b9c4e7a01
{
"deleted": true
}
Borrowing
POST /l/<id>/borrow reserves one permit. ttl (seconds) is how
long the lease is held before it auto-expires. wait (seconds, default
0) is how long the call may block when the pool is exhausted. Both are clamped
to their configured maximums.
POST /l/9f1c2e7a-5b3d-4c8e-a1f0-6d2b9c4e7a01/borrow
{
"ttl": 30,
"wait": 25
}
On success the response carries the lease UUID, the slot position you were
given, and the expiry:
{
"lease": "3a7d0b14-9e2c-4f6a-8b1d-0c5e2f9a7b34",
"position": 0,
"expires_at_unix": 1781337630,
"expires_in": 30
}
- If a permit is free, the call returns immediately. Otherwise it blocks and is woken the instant a permit is returned, a lease expires, or the count is raised — re-checking atomically each time so concurrent borrowers never over-issue.
-
If no permit frees up within
waitseconds, the call returns409 {"error":"no resource available"}. Withwait: 0an exhausted pool fails right away. -
Hold the returned
leaseUUID — you need it to return early. If you lose it, the permit still frees itself afterttlseconds.
Returning
POST /l/<id>/return frees a lease before its ttl elapses and
wakes any borrower waiting on the pool. It is idempotent: returning an already-expired,
already-returned, or unknown lease reports returned: false.
POST /l/9f1c2e7a-5b3d-4c8e-a1f0-6d2b9c4e7a01/return
{
"lease": "3a7d0b14-9e2c-4f6a-8b1d-0c5e2f9a7b34"
}
{
"returned": true
}
You never have to return: every lease is freed automatically ttl seconds after
it was borrowed. Returning early just makes the permit available sooner. Pick a
ttl long enough to finish the work but short enough that a crashed holder
doesn't strand the permit.
Errors
Errors use the HTTP status plus a compact JSON body. Treat 5xx as retryable
with backoff; treat 4xx as a caller problem to fix first.
{
"error": "human-readable reason"
}
400-
Malformed JSON, an ID that is not a UUID, or
count,ttl, orwaitoutside the allowed range. 404- No pool exists for that resource UUID (it was never registered or was deleted).
409-
Borrow could not acquire a permit within
waitseconds. Retry later or with a longerwait. 502- The database command failed. Safe to retry.