diff --git a/Cargo.lock b/Cargo.lock
index 98d416df..58117b75 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4595,11 +4595,11 @@ dependencies = [
name = "swarm-queue-client"
version = "0.1.0"
dependencies = [
- "anyhow",
"async-nats",
"reqwest 0.13.1",
"serde",
"serde_json",
+ "thiserror 2.0.18",
"tokio",
"tracing",
]
diff --git a/swarm-queue-client/Cargo.toml b/swarm-queue-client/Cargo.toml
index 1cdfff73..32a2d0e5 100644
--- a/swarm-queue-client/Cargo.toml
+++ b/swarm-queue-client/Cargo.toml
@@ -5,7 +5,6 @@ readme = "README.md"
edition.workspace = true
[dependencies]
-anyhow.workspace = true
# No `kv`/`jetstream` feature here on purpose: this crate's job ends at a
# connected client. What a consumer does with it - KV for the controller and
# the hive, plain messaging for anything later - is the consumer's business,
@@ -14,6 +13,9 @@ async-nats.workspace = true
reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
+# A library, so its errors are a matchable enum rather than an opaque
+# `anyhow::Error`. The binaries that consume this keep anyhow; `?` converts.
+thiserror.workspace = true
tokio.workspace = true
tracing.workspace = true
diff --git a/swarm-queue-client/src/lib.rs b/swarm-queue-client/src/lib.rs
index d1c8ad56..29def273 100644
--- a/swarm-queue-client/src/lib.rs
+++ b/swarm-queue-client/src/lib.rs
@@ -31,7 +31,78 @@
use std::path::PathBuf;
-use anyhow::{Context, Result, bail};
+/// Everything that can go wrong reaching the swarm queue.
+///
+/// `thiserror` and not `anyhow` because this is a library: a caller gets a
+/// type it can match on, and the binaries that consume it keep using
+/// `anyhow` — `?` converts for free, so nothing downstream is more verbose
+/// for it. The same split `hive-claude` uses.
+///
+/// The variants are the failures an operator acts on differently: a
+/// half-configured environment is a deployment bug, a refused token is an
+/// identity-provider config problem, an unreachable queue is a network
+/// one. Collapsing them into one string would make that distinction a
+/// matter of reading prose.
+#[derive(Debug, thiserror::Error)]
+pub enum Error {
+ #[error(
+ "swarm queue is half-configured: {prefix}_NATS_URL, \
+ {prefix}_OIDC_TOKEN_ENDPOINT, {prefix}_OIDC_CLIENT_ID and \
+ {prefix}_OIDC_CLIENT_SECRET_FILE must be set together or not at all"
+ )]
+ PartialConfig { prefix: String },
+
+ #[error("reading the queue client secret from {path}")]
+ ClientSecret {
+ path: String,
+ #[source]
+ source: std::io::Error,
+ },
+
+ #[error("building the token-endpoint HTTP client")]
+ HttpClient(#[source] reqwest::Error),
+
+ #[error("requesting an access token from authelia")]
+ TokenRequest(#[source] reqwest::Error),
+
+ /// The body carries authelia's own `error_description`, and it is far
+ /// more useful than the status alone: a wrong grant says
+ /// `unauthorized_client`, a wrong secret says `invalid_client`, and
+ /// those point at different config.
+ #[error("authelia refused the token request ({status}): {body}")]
+ TokenRefused {
+ status: reqwest::StatusCode,
+ body: String,
+ },
+
+ #[error("parsing authelia's token response")]
+ TokenResponse(#[source] serde_json::Error),
+
+ #[error("connecting to the swarm queue at {url}")]
+ Connect {
+ url: String,
+ #[source]
+ source: async_nats::ConnectError,
+ },
+}
+
+/// Render an error and its source chain on one line.
+///
+/// The auth callback below hands `async-nats` a *string*, so a `Display`
+/// that stopped at the top message would drop the cause — which is the
+/// half that says why the mint failed. `anyhow`'s `{:#}` did this for
+/// free; a library owes its callers the same detail without owing them
+/// anyhow.
+fn chain(error: &dyn std::error::Error) -> String {
+ let mut rendered = error.to_string();
+ let mut source = error.source();
+ while let Some(cause) = source {
+ rendered.push_str(": ");
+ rendered.push_str(&cause.to_string());
+ source = cause.source();
+ }
+ rendered
+}
/// Only the one field this needs; authelia returns several.
#[derive(serde::Deserialize)]
@@ -76,7 +147,7 @@ impl QueueConfig {
/// there would trade a missing feature for a dead process. What must NOT
/// happen is a *half* configuration silently behaving like an absent one —
/// hence the explicit partial check below.
- pub fn from_env(prefix: &str) -> Result