API overview
Ursula exposes a small public HTTP API for durable append-only streams. Most users only need the /{bucket}/{stream} route family, which maps cleanly to buckets and stream IDs.
This API is Ursula's implementation of the Durable Streams Protocol, defined by the durable-streams community, with additional route families for compatibility and deployment needs.
Bucket operations
| Method | Path | Description |
|---|---|---|
PUT | /{bucket} | Create a bucket (validates the ID; buckets are implicit) |
There is no bucket metadata read, stream listing, or bucket delete. Removing a bucket is an operator action (bucket purge), which permanently fences the bucket ID: later stream creates in it answer 404.
Stream operations
| Method | Path | Description |
|---|---|---|
PUT | /{bucket}/{stream} | Create a stream |
POST | /{bucket}/{stream} | Append data or close |
GET | /{bucket}/{stream} | Read (catch-up, long-poll, SSE) |
HEAD | /{bucket}/{stream} | Get stream metadata |
DELETE | /{bucket}/{stream} | Delete a stream |
Bootstrap and snapshots
| Method | Path | Description |
|---|---|---|
GET | /{bucket}/{stream}/bootstrap | Snapshot + tail replay |
GET | /{bucket}/{stream}/snapshot/{offset} | Read snapshot at offset |
PUT | /{bucket}/{stream}/snapshot/{offset} | Publish a snapshot |
GET | /{bucket}/{stream}/incarnation/{token}/chunk/{offset} | Read a chunk a cache can store |
GET | /{bucket}/{stream}/incarnation/{token}/snapshot/{offset} | Read a snapshot a cache can store |
Common request patterns
Create a bucket and stream
curl -X PUT http://127.0.0.1:4437/demo
curl -X PUT http://127.0.0.1:4437/demo/hello
Append data
curl -X POST http://127.0.0.1:4437/demo/hello \
-H 'Content-Type: application/octet-stream' \
--data-binary 'hello world'
Read from the beginning
curl 'http://127.0.0.1:4437/demo/hello?offset=-1'
Subscribe with SSE
curl 'http://127.0.0.1:4437/demo/hello?offset=-1&live=sse'
Errors
Every error answer from a node or from the gateway carries the same JSON body, the shape the Durable Streams protocol uses for its subscription API errors:
{"error":{"code":"STREAM_NOT_FOUND","message":"the stream does not exist"}}
error.code is a stable code that names the refusal, so match it instead of parsing text. error.message says why for a person reading it. A 4xx that only names what the request named says what was wrong with it, for example {"error":{"code":"INVALID_HEADER","message":"stream-ttl must not contain leading zeros"}}. Every other answer carries a fixed sentence per code, which names nothing of the cluster. The wording can change in any release, so never match it. It never echoes the request body or a credential. Ignore members you do not know, and treat a code you do not know by its status alone: a later 0.8 release may add both. The status and the typed headers keep their meaning beside it: a 413 names its limit in Ursula-Max-Request-Bytes or Ursula-Max-Snapshot-Bytes, a 503 carries Retry-After, a producer 403 carries Producer-Outcome. A 503 whose code is NO_LEADER means no leader of the stream's group is known yet. A layer in front of Ursula, such as a proxy, may answer with another body, so treat the code as optional and fall back on the status. A live SSE read that ends in error sends the same body as the data of an event: error. Error bodies lists every code. Rust clients parse the body with ursula_headers::ErrorBody.
The operator routes under /__ursula/ keep their own answers.
Write outcomes
A write whose answer is lost, such as one that timed out or got a 5xx, may have applied. A refusal says that its own request applied nothing only by its status or its error code, as write outcomes defines: a 4xx other than 408, or a 408 or 5xx whose code proves it, such as NO_LEADER before a proposal. OUTCOME_UNKNOWN, any other 5xx and a lost answer leave the outcome unknown. Resolve an unknown outcome by repeating the write with its key: the same Producer-Id, Producer-Epoch, Producer-Seq and body, and the same Producer-Digest and Stream-Incarnation when the first attempt carried them. The answer to the repeat is definite. A 200 or 204 means the write applied. A producer 403 carries Producer-Epoch and a Producer-Outcome that says what became of the write's epoch. A 409, or a 400 with Producer-Expected-Seq, names the seq the server expects next. See write keys and typed answers for every answer and exactly-once writes for the producer model.
A write without producer headers has no key. Repeating it can apply it twice.
Redirects and retries
A node that does not lead the stream's Raft group answers a write with a 307 to the leader, or 503 when it knows no leader yet, before it reads the body. Once it has read the body it never redirects: a node that lost the leadership meanwhile answers 503 with Retry-After. The gateway follows Raft leader redirects itself, so its clients never get one: up to three hops in a row, none started later than 5 s after the first redirect, and through the upstreams it has not tried yet when a redirect names an address that is none of them, or one it already went to. When no upstream answers without redirecting within those limits, the gateway answers 503 with Retry-After: 1. Each hop sends the request again. A node redirects only a write it has not read, so a redirect never follows a proposal. A 503 can follow one, so repeat a write whose outcome is unknown with its producer key: the server answers a repeat of an applied write as a duplicate. A plain append without a producer can apply twice when it is repeated. Use a producer for exactly-once writes.
For the same reason, a proxy or service mesh must not resend writes on its own. A resent write without producer headers applies twice. With ingress-nginx, set nginx.ingress.kubernetes.io/proxy-next-upstream: "off", as the chart's TLS ingress example does. In a service mesh, turn off retries for the stream routes on both sides of the gateway. Istio before 1.24, for example, resends a request up to twice by default when it gets a 503, and any version resends requests on a route whose retryOn includes 503, 5xx, gateway-error or reset. Istio 1.24 to 1.26 restores the default 503 retry when istiod runs with EXCLUDE_UNSAFE_503_FROM_DEFAULT_RETRY=false, which compatibilityVersion=1.23 sets. Set retries: {attempts: 0} on those routes in their VirtualService.
Related concepts
- Read modes: catch-up vs long-poll vs SSE
- Bootstrap: snapshot + tail recovery
- Snapshots: snapshot lifecycle
- Exactly-once writes: producer deduplication
- Conditional writes:
Stream-Seqas a compare-and-set