The admin listener (admin.port, default 4002): health, metrics, the
effective configuration, the dead-letter queue, the quarantine, and replay
jobs that re-deliver from either (or from the archive).
Responses are decoded JSON with keyword keys (metrics returns the
Prometheus text). Failures carry :retryable:
| Error | :retryable | Cause |
|---|---|---|
RoleNotEnabledError | false | 409 role_not_enabled: ask another node (:role names it) |
AdminRejectedError | false | any other 4xx (:status, :code, :scope) |
AdminUnavailableError | true | 5xx, a redirect, a non-JSON body, or unreachable |
Every call is one request: no retry, no followed redirect.
The admin listener (`admin.port`, default `4002`): health, metrics, the effective configuration, the dead-letter queue, the quarantine, and replay jobs that re-deliver from either (or from the archive). Responses are decoded JSON with keyword keys (`metrics` returns the Prometheus text). Failures carry `:retryable`: | Error | `:retryable` | Cause | | --- | --- | --- | | `RoleNotEnabledError` | false | `409 role_not_enabled`: ask another node (`:role` names it) | | `AdminRejectedError` | false | any other `4xx` (`:status`, `:code`, `:scope`) | | `AdminUnavailableError` | true | `5xx`, a redirect, a non-JSON body, or unreachable | Every call is one request: no retry, no followed redirect.
The claim-check gateway client: fetch the bytes a claim ref names and verify them against the digest the queue message carries.
(def gateway (claim-check/client "http://localhost:4004"))
(claim-check/redeem gateway (:claim message) (:sha256 message))
The gateway never checks the digest itself, so redeem always does: the
reader's own end-to-end check, run before the bytes are returned.
Failures are ex-infos; see ankusa.sdk.errors for the types and the
:retryable bit. Every call is one request: no retry, no followed redirect.
The claim-check gateway client: fetch the bytes a claim ref names and verify them against the digest the queue message carries. ```clojure (def gateway (claim-check/client "http://localhost:4004")) (claim-check/redeem gateway (:claim message) (:sha256 message)) ``` The gateway never checks the digest itself, so `redeem` always does: the reader's own end-to-end check, run before the bytes are returned. Failures are `ex-info`s; see `ankusa.sdk.errors` for the types and the `:retryable` bit. Every call is one request: no retry, no followed redirect.
A claim-check ref: urn:ankusa:claim:v1:<tenant>:<claim_id>, or the signed,
expiring urn:ankusa:claim:v2:<tenant>:<claim_id>:<exp_unix>:<signature>; the
string a queue message carries in its "claim" field once its body outgrows
the sink's inline limit.
The claim id is a Crockford base32 ULID: 26 characters, no I, L, O or
U, and a first character in 0..7 so the timestamp stays in range.
exp_unix is a decimal of 1-12 digits with no leading zero and signature
43 unpadded base64url characters. The gateway checks the signature and the
expiry; the SDK only carries them in the path.
A claim-check ref: `urn:ankusa:claim:v1:<tenant>:<claim_id>`, or the signed, expiring `urn:ankusa:claim:v2:<tenant>:<claim_id>:<exp_unix>:<signature>`; the string a queue message carries in its `"claim"` field once its body outgrows the sink's inline limit. The claim id is a Crockford base32 ULID: 26 characters, no `I`, `L`, `O` or `U`, and a first character in `0`..`7` so the timestamp stays in range. `exp_unix` is a decimal of 1-12 digits with no leading zero and `signature` 43 unpadded base64url characters. The gateway checks the signature and the expiry; the SDK only carries them in the path.
Classifying what the SDK throws.
Every failure the SDK raises for a bad response, an unreachable server, or a
bad message is an ex-info whose ex-data has a :type keyword in the
ankusa.sdk namespace and the type's own keys, all always present (nil
when they do not apply). Keys are kebab-case.
:type (in :ankusa.sdk) | :retryable | other keys |
|---|---|---|
InvalidClaimRefError | false | |
ClaimNotFoundError | false | |
ClaimRejectedError | false | :status, :body |
ClaimIntegrityError | false | |
ClaimCheckUnavailableError | true | :status, :reason |
MissingHookIdError | false | |
InvalidSignatureError | false | :code, :field |
InvalidRouteIdError | false | |
RouteNotFoundError | false | |
RoutesRejectedError | false | :status, :code, :field, :message, :conflicting-id, :max-routes, :scope |
RoutesUnavailableError | true | :status, :reason |
RoleNotEnabledError | false | :role |
AdminRejectedError | false | :status, :code, :scope |
AdminUnavailableError | true | :status, :reason |
InvalidMessageError | false | :code, :field |
SourceNotFoundError, SourceConflictError, SourceStoreReadOnlyError, SourceInvalidError, VersionMismatchError | false | :status, :body |
SourcesUnavailableError | true | :status, :body |
:reason is one of :status (the server answered a status the client
cannot use; :status holds it), :transport (nothing usable came back;
:status is nil and the exception that caused it is the ex-cause), or
:invalid-json (a 200 whose body was not JSON).
Caller misuse — a bad base URL, a bad option, a bad header, a non-positive
timeout, input decode cannot read — throws IllegalArgumentException
instead: it is a bug to fix, not a failure to handle.
Classifying what the SDK throws. Every failure the SDK raises for a bad response, an unreachable server, or a bad message is an `ex-info` whose `ex-data` has a `:type` keyword in the `ankusa.sdk` namespace and the type's own keys, all always present (`nil` when they do not apply). Keys are kebab-case. | `:type` (in `:ankusa.sdk`) | `:retryable` | other keys | | --- | --- | --- | | `InvalidClaimRefError` | false | | | `ClaimNotFoundError` | false | | | `ClaimRejectedError` | false | `:status`, `:body` | | `ClaimIntegrityError` | false | | | `ClaimCheckUnavailableError` | true | `:status`, `:reason` | | `MissingHookIdError` | false | | | `InvalidSignatureError` | false | `:code`, `:field` | | `InvalidRouteIdError` | false | | | `RouteNotFoundError` | false | | | `RoutesRejectedError` | false | `:status`, `:code`, `:field`, `:message`, `:conflicting-id`, `:max-routes`, `:scope` | | `RoutesUnavailableError` | true | `:status`, `:reason` | | `RoleNotEnabledError` | false | `:role` | | `AdminRejectedError` | false | `:status`, `:code`, `:scope` | | `AdminUnavailableError` | true | `:status`, `:reason` | | `InvalidMessageError` | false | `:code`, `:field` | | `SourceNotFoundError`, `SourceConflictError`, `SourceStoreReadOnlyError`, `SourceInvalidError`, `VersionMismatchError` | false | `:status`, `:body` | | `SourcesUnavailableError` | true | `:status`, `:body` | `:reason` is one of `:status` (the server answered a status the client cannot use; `:status` holds it), `:transport` (nothing usable came back; `:status` is `nil` and the exception that caused it is the `ex-cause`), or `:invalid-json` (a `200` whose body was not JSON). Caller misuse — a bad base URL, a bad option, a bad header, a non-positive timeout, input `decode` cannot read — throws `IllegalArgumentException` instead: it is a bug to fix, not a failure to handle.
The idempotency key for one delivery, whichever transport delivered it.
Delivery is at-least-once: a provider retry, a sink retry, or a requeued
message can hand the same hook over more than once. Ankusa computes one
tenant-scoped key per hook and ships it (the message's idempotency_key, the
x-ankusa-idempotency-key header); this namespace returns that value. Read
it, do not rebuild it.
For a message or delivery from a node that predates the field, the key is computed from the hook's own fields, by the same rule core uses:
tenant:source:dedupe-key when the dedupe key is non-empty
id otherwise
where the tenant is "default" when there is none. A replay of an older
delivery carries a replay id. The key ignores it by default, so a consumer that
already processed the original drops the replay; one that must reprocess
replays passes {:include-replay true}, which appends #replay:<replay-id>.
Accepts a decoded message (ankusa.sdk.message/decode), a hook
(ankusa.sdk.message/->hook), or parsed headers
(ankusa.sdk.webhook/parse-headers).
The idempotency key for one delivery, whichever transport delivered it.
Delivery is at-least-once: a provider retry, a sink retry, or a requeued
message can hand the same hook over more than once. Ankusa computes one
tenant-scoped key per hook and ships it (the message's `idempotency_key`, the
`x-ankusa-idempotency-key` header); this namespace returns that value. Read
it, do not rebuild it.
For a message or delivery from a node that predates the field, the key is
computed from the hook's own fields, by the same rule core uses:
tenant:source:dedupe-key when the dedupe key is non-empty
id otherwise
where the tenant is `"default"` when there is none. A replay of an older
delivery carries a replay id. The key ignores it by default, so a consumer that
already processed the original drops the replay; one that must reprocess
replays passes `{:include-replay true}`, which appends `#replay:<replay-id>`.
Accepts a decoded message (`ankusa.sdk.message/decode`), a hook
(`ankusa.sdk.message/->hook`), or parsed headers
(`ankusa.sdk.webhook/parse-headers`).The SHA-256 every integrity check compares: lowercase hex, 64 characters.
The SHA-256 every integrity check compares: lowercase hex, 64 characters.
Builds the ex-info every SDK failure is raised as.
The :type of its ex-data names the failure (:ankusa.sdk/ClaimNotFoundError)
and the rest of the data is that type's fixed key set: every key is present,
nil when it does not apply, so callers can read any of them without a
contains? guard. See ankusa.sdk.errors.
Builds the `ex-info` every SDK failure is raised as. The `:type` of its `ex-data` names the failure (`:ankusa.sdk/ClaimNotFoundError`) and the rest of the data is that type's fixed key set: every key is present, `nil` when it does not apply, so callers can read any of them without a `contains?` guard. See `ankusa.sdk.errors`.
Reading request headers the way every receiver-side helper does.
Reading request headers the way every receiver-side helper does.
The one place the SDK issues requests.
Conventions every caller relies on, because the conformance vectors count requests and bound latency:
3xx is classified as unavailable.The one place the SDK issues requests. Conventions every caller relies on, because the conformance vectors count requests and bound latency: * One request per call. There is no retry. * A redirect is never followed: a `3xx` is classified as `unavailable`. * A non-2xx status is a value each client maps to its own error, never an exception. * Bodies stay bytes; the caller decides whether they are claim bytes, JSON, or Prometheus text.
Decode a lifecycle event: the CloudEvents 1.0 (structured mode) event an Ankusa instance publishes when a source or a route is created, updated or deleted, when a tenant is erased, or when the instance is restored from a backup.
It arrives as the body of a queue message from the instance's lifecycle
sinks (content type application/cloudevents+json, source_id
ankusa:lifecycle): decode the message with ankusa.sdk.message/decode,
take the body from ankusa.sdk.message/->hook, and hand it to decode.
(let [event (lifecycle/decode (:body hook))]
(:type event)) ;=> "io.ankusa.source.created"
Delivery is at least once and in commit order. :id is derived from the
change, so the same event always has the same id, and :revision (per
:subject) lets a consumer ignore a stale update.
Every failure is an :ankusa.sdk/InvalidMessageError with a stable :code
("invalid_json", "not_an_object", "unsupported_version" when the
event's specversion is not "1.0", or "invalid_field" and the
offending :field). It is never retryable.
Decode a lifecycle event: the CloudEvents 1.0 (structured mode) event an Ankusa instance publishes when a source or a route is created, updated or deleted, when a tenant is erased, or when the instance is restored from a backup. It arrives as the body of a queue message from the instance's lifecycle sinks (content type `application/cloudevents+json`, `source_id` `ankusa:lifecycle`): decode the message with `ankusa.sdk.message/decode`, take the body from `ankusa.sdk.message/->hook`, and hand it to `decode`. ```clojure (let [event (lifecycle/decode (:body hook))] (:type event)) ;=> "io.ankusa.source.created" ``` Delivery is at least once and in commit order. `:id` is derived from the change, so the same event always has the same id, and `:revision` (per `:subject`) lets a consumer ignore a stale update. Every failure is an `:ankusa.sdk/InvalidMessageError` with a stable `:code` (`"invalid_json"`, `"not_an_object"`, `"unsupported_version"` when the event's `specversion` is not `"1.0"`, or `"invalid_field"` and the offending `:field`). It is never retryable.
Decode and verify a queue message: the JSON payload Ankusa's broker sinks (RabbitMQ, Kafka, NATS) publish.
(let [message (message/decode payload)
hook (message/->hook message gateway)]
(handle hook (idempotency/key hook)))
A message carries its body inline (body_base64) or by reference (claim,
redeemed from the claim-check gateway). decode never touches the network:
an inline body is decoded and verified, a claim is only parsed.
Every failure is an :ankusa.sdk/InvalidMessageError with a stable :code
("invalid_json", "not_an_object", "unsupported_version",
"invalid_field", "ambiguous_body", "missing_body",
"invalid_body_base64", "size_mismatch", "integrity",
"tenant_mismatch") and, for "invalid_field", the offending :field
(otherwise nil). It is never retryable: the same bytes fail the same way.
Decode and verify a queue message: the JSON payload Ankusa's broker sinks
(RabbitMQ, Kafka, NATS) publish.
```clojure
(let [message (message/decode payload)
hook (message/->hook message gateway)]
(handle hook (idempotency/key hook)))
```
A message carries its body inline (`body_base64`) or by reference (`claim`,
redeemed from the claim-check gateway). `decode` never touches the network:
an inline body is decoded and verified, a claim is only parsed.
Every failure is an `:ankusa.sdk/InvalidMessageError` with a stable `:code`
(`"invalid_json"`, `"not_an_object"`, `"unsupported_version"`,
`"invalid_field"`, `"ambiguous_body"`, `"missing_body"`,
`"invalid_body_base64"`, `"size_mismatch"`, `"integrity"`,
`"tenant_mismatch"`) and, for `"invalid_field"`, the offending `:field`
(otherwise `nil`). It is never retryable: the same bytes fail the same way.The route-management listener (routes.admin.port, default 4003): the
route table the edge enforces, the global IP rules, and a dry run that
replays the guard's decision.
The listener performs no authentication of its own; :headers is for
whatever a deployer's boundary (a service mesh, an API gateway) expects in
front of it.
Route ids are percent-encoded as one path segment, so /, ?, # and %
in an id cannot reshape the URL. An id that is not a string, is empty, or is
. or .. is refused with :ankusa.sdk/InvalidRouteIdError before any
request is sent: URL parsers normalize those away, so (get-route c "..")
would quietly hit /admin/ and return the list page as if it were a route.
Responses are decoded JSON with keyword keys. Failures carry :retryable:
| Error | :retryable | Cause |
|---|---|---|
InvalidRouteIdError | false | unusable id, caught before the request |
RouteNotFoundError | false | 404 |
RoutesRejectedError | false | any other 4xx (:scope when the body carries it) |
RoutesUnavailableError | true | 5xx, a redirect, a non-JSON body, or unreachable |
Every call is one request: no retry, no followed redirect.
The route-management listener (`routes.admin.port`, default `4003`): the route table the edge enforces, the global IP rules, and a dry run that replays the guard's decision. The listener performs no authentication of its own; `:headers` is for whatever a deployer's boundary (a service mesh, an API gateway) expects in front of it. Route ids are percent-encoded as one path segment, so `/`, `?`, `#` and `%` in an id cannot reshape the URL. An id that is not a string, is empty, or is `.` or `..` is refused with `:ankusa.sdk/InvalidRouteIdError` before any request is sent: URL parsers normalize those away, so `(get-route c "..")` would quietly hit `/admin/` and return the list page as if it were a route. Responses are decoded JSON with keyword keys. Failures carry `:retryable`: | Error | `:retryable` | Cause | | --- | --- | --- | | `InvalidRouteIdError` | false | unusable id, caught before the request | | `RouteNotFoundError` | false | `404` | | `RoutesRejectedError` | false | any other `4xx` (`:scope` when the body carries it) | | `RoutesUnavailableError` | true | `5xx`, a redirect, a non-JSON body, or unreachable | Every call is one request: no retry, no followed redirect.
Verify the Standard Webhooks signature (https://www.standardwebhooks.com/)
an HTTP sink with a secret adds to every delivery.
webhook-signature holds space-separated v1,<base64> entries, each an
HMAC-SHA256 over <webhook-id>.<webhook-timestamp>.<body>. A delivery passes
when any v1 entry matches any configured secret (several during a
rotation), compared in constant time with MessageDigest/isEqual, and
webhook-timestamp is within the tolerance of now. Secrets are whsec_ +
base64, or any other string used as its own UTF-8 bytes.
A failure throws :ankusa.sdk/InvalidSignatureError with :code
("invalid_secret", "missing_header", "invalid_timestamp",
"timestamp_out_of_tolerance", "no_matching_signature") and :field
(the header at fault, nil for "invalid_secret"). It is never
retryable: answer 401.
Verify the Standard Webhooks signature (https://www.standardwebhooks.com/) an HTTP sink with a `secret` adds to every delivery. `webhook-signature` holds space-separated `v1,<base64>` entries, each an HMAC-SHA256 over `<webhook-id>.<webhook-timestamp>.<body>`. A delivery passes when any `v1` entry matches any configured secret (several during a rotation), compared in constant time with `MessageDigest/isEqual`, and `webhook-timestamp` is within the tolerance of now. Secrets are `whsec_` + base64, or any other string used as its own UTF-8 bytes. A failure throws `:ankusa.sdk/InvalidSignatureError` with `:code` (`"invalid_secret"`, `"missing_header"`, `"invalid_timestamp"`, `"timestamp_out_of_tolerance"`, `"no_matching_signature"`) and `:field` (the header at fault, `nil` for `"invalid_secret"`). It is never retryable: answer `401`.
The tenant-scoped source-management API: the admin listener (admin.port,
default 4002) serves list/get/create/update/delete for a tenant's ingest
sources.
A source is addressed as <tenant>.<name>; this client speaks in those terms
and builds the paths for you. Only [A-Za-z0-9_-]{1,64} tenants and names
are accepted, and both are checked before any path is built, so a
caller-supplied name cannot escape its tenant through URL normalization.
A spec is {:sinks [...] :verify {...} :on-verify-failure "reject"}.
:sinks is required by the server and must be non-empty. :verify is
optional: absent means the server's default ({"type" "none"}), because
omitting it is not the same as re-sending a secret the API never returns.
nil fields are omitted from the body.
A source is {:tenant :name :source-id :ingest-path :verify :on-verify-failure :sinks}. Every value is redacted by the server (secrets,
passwords, tokens, header values), so a source read back is never useful for
editing: resending its :verify is not the same as resending the stored
secret.
:expected-version is an optional safety latch: when set, every API call
first makes sure GET /health reported that version, and a mismatch is a
VersionMismatchError before the request is sent.
The client is immutable, so the fetched version is returned rather than
remembered. Run verify-version once at startup and keep the returned
client; every later call then re-checks the cached version without another
/health request:
(def sources (sources/verify-version (sources/client url {:expected-version "0.3.0"})))
(sources/list-sources sources "acme")
Failures: SourceNotFoundError (404), SourceConflictError (409),
SourceStoreReadOnlyError (409 source_store_read_only: the deployment's
source store is a static seed), SourceInvalidError (400, or an invalid
tenant or name caught before any request), VersionMismatchError, and
SourcesUnavailableError (unreachable, timed out, or 5xx). Every one
carries :status and :body (nil when no response was involved).
The tenant-scoped source-management API: the admin listener (`admin.port`,
default `4002`) serves list/get/create/update/delete for a tenant's ingest
sources.
A source is addressed as `<tenant>.<name>`; this client speaks in those terms
and builds the paths for you. Only `[A-Za-z0-9_-]{1,64}` tenants and names
are accepted, and both are checked before any path is built, so a
caller-supplied name cannot escape its tenant through URL normalization.
A spec is `{:sinks [...] :verify {...} :on-verify-failure "reject"}`.
`:sinks` is required by the server and must be non-empty. `:verify` is
optional: absent means the server's default (`{"type" "none"}`), because
omitting it is not the same as re-sending a secret the API never returns.
`nil` fields are omitted from the body.
A source is `{:tenant :name :source-id :ingest-path :verify
:on-verify-failure :sinks}`. Every value is redacted by the server (secrets,
passwords, tokens, header values), so a source read back is never useful for
editing: resending its `:verify` is not the same as resending the stored
secret.
## The version latch
`:expected-version` is an optional safety latch: when set, every API call
first makes sure `GET /health` reported that version, and a mismatch is a
`VersionMismatchError` before the request is sent.
The client is immutable, so the fetched version is returned rather than
remembered. Run `verify-version` once at startup and keep the returned
client; every later call then re-checks the cached version without another
`/health` request:
```clojure
(def sources (sources/verify-version (sources/client url {:expected-version "0.3.0"})))
(sources/list-sources sources "acme")
```
Failures: `SourceNotFoundError` (`404`), `SourceConflictError` (`409`),
`SourceStoreReadOnlyError` (`409 source_store_read_only`: the deployment's
source store is a static seed), `SourceInvalidError` (`400`, or an invalid
tenant or name caught before any request), `VersionMismatchError`, and
`SourcesUnavailableError` (unreachable, timed out, or `5xx`). Every one
carries `:status` and `:body` (`nil` when no response was involved).How the node verified a hook at ingest, and whether to trust it.
Every delivery carries :verification: {:status "ok" | "failed" | "skipped", :flagged boolean}, or nil from a node older than the field. A
source with on_verify_failure: accept_flag commits a hook whose signature
check failed, with :flagged true; nothing about that hook proves the
provider sent it, and neither does the tenant it names.
Accepts a decoded message (ankusa.sdk.message/decode), a hook
(ankusa.sdk.message/->hook), parsed headers
(ankusa.sdk.webhook/parse-headers) or a received delivery
(ankusa.sdk.webhook/receive-delivery).
How the node verified a hook at ingest, and whether to trust it.
Every delivery carries `:verification`: `{:status "ok" | "failed" |
"skipped", :flagged boolean}`, or `nil` from a node older than the field. A
source with `on_verify_failure: accept_flag` commits a hook whose signature
check failed, with `:flagged true`; nothing about that hook proves the
provider sent it, and neither does the tenant it names.
Accepts a decoded message (`ankusa.sdk.message/decode`), a hook
(`ankusa.sdk.message/->hook`), parsed headers
(`ankusa.sdk.webhook/parse-headers`) or a received delivery
(`ankusa.sdk.webhook/receive-delivery`).The headers every receiver of Ankusa's HTTP sink needs, parsed off a request.
The HTTP sink sends these headers; the hook id is the only one that is always there:
| Header | Key | Default |
|---|---|---|
x-ankusa-id | :id | required: its absence means the request is not a delivery |
x-ankusa-source | :source | "" |
x-ankusa-tenant | :tenant | nil |
content-type | :content-type | nil |
x-ankusa-dedupe-key | :dedupe-key | nil (also when empty) |
x-ankusa-replay-id | :replay-id | nil (also when empty) |
x-ankusa-idempotency-key | :idempotency-key | nil (also when empty) |
x-ankusa-received-at | :received-at | nil (absent, empty, or not 1 to 15 decimal digits); unix milliseconds the edge accepted the hook |
x-ankusa-body-sha256 | :body-sha256 | nil (absent, or not exactly 64 lowercase hex characters); the SHA-256 of the body sent |
x-ankusa-verification / x-ankusa-verification-flagged | :verification | nil (also when the status header is empty) |
The result also carries every request header under :headers, lowercased, so
a receiver can read the provider's own headers the sink forwarded.
The headers every receiver of Ankusa's HTTP sink needs, parsed off a request. The HTTP sink sends these headers; the hook id is the only one that is always there: | Header | Key | Default | | --- | --- | --- | | `x-ankusa-id` | `:id` | required: its absence means the request is not a delivery | | `x-ankusa-source` | `:source` | `""` | | `x-ankusa-tenant` | `:tenant` | `nil` | | `content-type` | `:content-type` | `nil` | | `x-ankusa-dedupe-key` | `:dedupe-key` | `nil` (also when empty) | | `x-ankusa-replay-id` | `:replay-id` | `nil` (also when empty) | | `x-ankusa-idempotency-key` | `:idempotency-key` | `nil` (also when empty) | | `x-ankusa-received-at` | `:received-at` | `nil` (absent, empty, or not 1 to 15 decimal digits); unix milliseconds the edge accepted the hook | | `x-ankusa-body-sha256` | `:body-sha256` | `nil` (absent, or not exactly 64 lowercase hex characters); the SHA-256 of the body sent | | `x-ankusa-verification` / `x-ankusa-verification-flagged` | `:verification` | `nil` (also when the status header is empty) | The result also carries every request header under `:headers`, lowercased, so a receiver can read the provider's own headers the sink forwarded.
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |