crates/gunnar-forge/src/error.rs
//! **Errors that cannot carry a token, by construction rather than by care.**
//!
//! # The threat, stated exactly
//!
//! A personal access token is a bearer credential with, on GitHub's classic
//! tokens, no expiry at all. It reaches this crate as one `&str` argument. The
//! ways a crate like this normally leaks one are all *accidents of
//! convenience*:
//!
//! 1. an error type that keeps the failing **request** so the message can say
//! what was attempted — and a request carries its headers;
//! 2. a `#[derive(Debug)]` on a client struct that **stored** the token in a
//! field, printed by `{:?}`, by `unwrap()`'s panic message, and by every
//! `tracing` span that takes the struct;
//! 3. a transport error whose `Display` is the underlying library's, which
//! includes the URL — harmless while the token is a header, fatal the day
//! someone "simplifies" it into a query parameter.
//!
//! # What this module does instead
//!
//! **The token is never stored.** [`crate::Forge`] holds a kind, a base URL
//! and a `ureq::Agent`. The token is a parameter of `list_repos` and dies with
//! the call. There is no field to print, so #2 is not defended against — it is
//! impossible.
//!
//! **No variant holds a request, a response body, a header map or a URL.** The
//! most any of them carries is the forge name, an HTTP status, and — for
//! [`ForgeError::RateLimited`] — a `Retry-After` duration, which is a number.
//! So #1 and #3 have nothing to reach. `Transport` keeps ureq's *kind*
//! (`Dns`, `ConnectionFailed`, …) and drops its `Display`, which is the one
//! place a URL would have got in.
//!
//! **`Malformed` is the one variant with free text**, and it is written by
//! this crate's own parsers about JSON that came back from the forge. It never
//! sees the request side. Its text is a field name, never a value, because a
//! *value* could be an OAuth device code or a repository someone's employer
//! would rather not see in a log.
//!
//! `tests/token_never_leaks.rs` asserts all of this against a real token
//! string driven through every failure path, including the panic message of a
//! deliberate `{:?}` on the client. Seen RED first against a draft whose
//! `Transport` variant rendered ureq's own `Display`, which put the whole
//! request — path, query string and all — into the message; the measured
//! output is quoted at the top of that file.
use std::time::Duration;
use crate::repo::ForgeKind;
/// Everything that can go wrong listing repositories.
///
/// See the module docs for why no variant carries a request.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum ForgeError {
/// 401. The token is missing, wrong, revoked, or expired.
///
/// It names the forge because a caller holding three tokens has to know
/// *which* one to re-issue, and that is the entire useful content of a
/// 401 — the body GitHub returns is `{"message":"Bad credentials", ...}`,
/// which adds nothing and is one field rename away from echoing input.
#[error("{forge}: the token was rejected (HTTP 401); re-issue it and try again")]
Unauthorized {
/// Which of the caller's forges rejected the credential.
forge: ForgeKind,
},
/// 403 or 429 with a rate limit.
///
/// GitHub answers a secondary rate limit with **403** and a `Retry-After`
/// in seconds; the primary limit is a 403 with `x-ratelimit-remaining: 0`
/// and no `Retry-After` at all. GitLab and Forgejo use 429. All three land
/// here, because a caller's response to every one of them is the same: wait
/// and ask again, or tell the human how long.
///
/// `retry_after` is `None` when the forge did not say — which is not the
/// same as "retry immediately", and a caller that treats it as zero will
/// be rate-limited again inside a second.
#[error("{forge}: rate limited (HTTP {status}){}", match retry_after {
Some(d) => format!("; retry after {}s", d.as_secs()),
None => String::new(),
})]
RateLimited {
/// Which forge is throttling.
forge: ForgeKind,
/// 403 (GitHub's secondary limit) or 429 (everyone else).
status: u16,
/// What the forge asked us to wait, when it said.
retry_after: Option<Duration>,
},
/// Any other non-2xx. The status is the whole message on purpose.
#[error("{forge}: the forge answered HTTP {status}")]
Http {
/// Which forge answered.
forge: ForgeKind,
/// The status it answered with.
status: u16,
},
/// The request never got an answer: DNS, TCP, TLS, timeout.
///
/// `kind` is ureq's `ErrorKind` rendered as a short static word. ureq's own
/// `Display` for a transport error embeds the URL, and this crate refuses
/// to put a URL in an error — see the module docs, reason #3.
#[error("{forge}: could not reach the forge ({kind})")]
Transport {
/// Which forge could not be reached.
forge: ForgeKind,
/// A short static word for the failure. Never a URL — see the docs.
kind: &'static str,
},
/// The forge answered 2xx with something this crate could not read.
///
/// `what` names a JSON *field or shape*, never a value.
#[error("{forge}: could not read the repository list ({what})")]
Malformed {
/// Which forge sent the unreadable body.
forge: ForgeKind,
/// A JSON field or shape. NEVER a value.
what: String,
},
/// Pagination did not terminate.
///
/// A forge that keeps saying "there is a next page" — a proxy that ignores
/// the `page` parameter is the realistic cause — would otherwise be an
/// unbounded loop holding a token in memory. See `ListOptions::max_pages`.
#[error("{forge}: stopped after {pages} pages; the forge never said it was done")]
TooManyPages {
/// Which forge would not stop paginating.
forge: ForgeKind,
/// How many pages were fetched before giving up.
pages: u32,
},
}
impl ForgeError {
/// The forge that failed. Every variant has one, which is what makes a
/// multi-forge picker able to grey out one row and keep the other two.
pub fn forge(&self) -> ForgeKind {
match self {
ForgeError::Unauthorized { forge }
| ForgeError::RateLimited { forge, .. }
| ForgeError::Http { forge, .. }
| ForgeError::Transport { forge, .. }
| ForgeError::Malformed { forge, .. }
| ForgeError::TooManyPages { forge, .. } => *forge,
}
}
/// The HTTP status, when there was a response at all.
pub fn status(&self) -> Option<u16> {
match self {
ForgeError::Unauthorized { .. } => Some(401),
ForgeError::RateLimited { status, .. } | ForgeError::Http { status, .. } => {
Some(*status)
}
_ => None,
}
}
/// Is waiting the right response? True for a rate limit and for a
/// transport failure, false for a bad token — retrying a 401 forever is
/// the loop that gets an account locked.
pub fn is_retryable(&self) -> bool {
matches!(
self,
ForgeError::RateLimited { .. } | ForgeError::Transport { .. }
)
}
}