Skip to content

Error variants

Every fallible operation in the crate returns CredentialError. It derives Debug, Clone, Error (via thiserror) and Diagnostic (via miette), and is #[non_exhaustive].

The five variants

Variant Message Diagnostic code
NotFound { name: String } credential not found: {name} rtb::credentials::not_found
LiteralRefusedInCi literal credential is refused in CI environments rtb::credentials::literal_refused
Keychain(String) keychain backend error: {0} rtb::credentials::keychain
ReadOnly this credential store is read-only rtb::credentials::read_only
Io(Arc<std::io::Error>) I/O error: {0} rtb::credentials::io

Because the enum is #[non_exhaustive], a downstream match needs a wildcard arm, and new variants can be added without a major version bump.

NotFound — nothing in the chain produced a value

Returned by Resolver::resolve when every configured leg missed, and by MemoryStore, EnvStore and KeyringStore when the requested entry is absent.

The name field is a diagnostic label, not a machine-readable key. What it contains depends on the producer:

Producer name
MemoryStore / KeyringStore "{service}/{account}"
EnvStore the environment variable name
Resolver::resolve fallback_env, else env, else "{service}/{account}", else <unnamed credential>

NotFound from a store during the resolver's keychain leg is not surfaced to the caller — the resolver treats it as a miss and continues down the chain.

LiteralRefusedInCi — a literal secret was declined under CI

Returned by Resolver::resolve when cref.literal is set and the CI environment variable is exactly "true".

The variant carries no payload, and the resolver returns it instead of continuing to the fallback_env leg.

Its miette help text is:

set CI=false locally, or move the secret to a keychain/env var

This is a policy refusal, not a technical failure. The secret was present and readable; the crate declined to hand it over.

Keychain(String) — the backend failed, or a lock was poisoned

Wraps a message from the platform keychain. Produced by KeyringStore for any keyring error other than "no entry", including a failure to construct the entry, and for a spawn_blocking join failure — the latter formatted as join error: {e}.

MemoryStore also returns this variant, with the message in-memory lock poisoned, when its RwLock has been poisoned by a panic in another thread. There is no separate variant for that case, so matching on Keychain does not prove a keychain was involved.

The inner String is a rendered message. It is not structured and should not be parsed to distinguish causes.

ReadOnly — the store does not support mutation

Returned by EnvStore::set, EnvStore::delete, LiteralStore::set and LiteralStore::delete.

It signals a permanent property of the store, not a transient condition or a permissions problem — retrying, or running with more privilege, will not change the outcome. MemoryStore and KeyringStore never return it.

Io — reserved, and never produced by this crate

Io exists and converts from std::io::Error through a From impl:

impl From<std::io::Error> for CredentialError {
    fn from(e: std::io::Error) -> Self {
        Self::Io(Arc::new(e))
    }
}

No built-in store returns it. Nothing in the crate constructs Io outside that conversion, so matching on it while using only the four supplied stores matches a branch that cannot currently be reached.

It is there for downstream CredentialStore implementations — a file-backed or network-backed store can use ? on an I/O operation and get the conversion for free.

Why the enum is Clone when io::Error is not

std::io::Error does not implement Clone, which would normally make any enum containing it non-Clone. The Io variant wraps its error in an Arc specifically to avoid that.

The reason is fan-out. One credential failure often needs to reach several consumers at once — a log line, a telemetry event, a health-check aggregate — and a Clone-able error lets each take a copy instead of the producer having to rebuild an equivalent error per consumer or hand out references with awkward lifetimes.

The cost is that cloning an Io error shares one underlying io::Error rather than duplicating it. Since the error is only ever read, that is invisible in practice.

Rendering an error to a user

CredentialError implements miette::Diagnostic, so a tool whose main returns miette::Result gets the code and help text rendered without extra work. The miette dependency is declared with the fancy feature, which pulls in the graphical report renderer.

fn main() -> miette::Result<()> {
    // a CredentialError propagated with `?` renders with its
    // rtb::credentials::* code and any help text attached
    Ok(())
}

Only LiteralRefusedInCi carries help text. The other four variants have a diagnostic code but no help, so they render as the message and code alone.