crates/gunnar-lfs/src/error.rs
//! Failures, and the HTTP status each becomes on the wire.
//!
//! The pure-SSH protocol answers every command with an HTTP status code. The
//! same codes the HTTP batch API uses, deliberately, so a server can proxy to an
//! HTTP backend. The mapping lives here, once, so no call site invents a code.
/// What went wrong.
#[derive(Debug, thiserror::Error)]
pub enum Error {
/// The stream failed, or ended mid-frame. **Fatal to the session**: there
/// is no status to answer with, because there is no longer a channel to
/// answer on.
#[error("i/o error: {0}")]
Io(#[from] std::io::Error),
/// The framing was malformed. Also fatal: once a pkt-line boundary is lost
/// there is no way to find the next command, and answering "400" into a
/// desynced stream would put four more bytes of garbage on it.
#[error("pkt-line framing: {0}")]
Framing(String),
/// The client sent something this protocol does not define. Answered, and
/// the session continues.
#[error("{0}")]
BadRequest(String),
/// The client asked for something the operation does not admit, a
/// `put-object` on a `download` session for instance.
///
/// **405 and not the 403 `docs/api/batch.md` names, and the difference is
/// measured.** That table's 403 is *"the user has **read**, but not
/// **write** access. Only applicable when the `operation` in the request is
/// upload"*, and it governs the **batch endpoint**. This is the object
/// endpoint, and there git-lfs reads 403 as something else entirely:
/// `tq/ssh.go`, in `SSHAdapter.upload`,
///
/// ```text
/// // A status code of 403 likely means that an authentication token for the
/// // upload has expired. This can be safely retried.
/// if status == 403 {
/// return errors.NewRetriableError(err)
/// }
/// ```
///
/// so 403 buys `lfs.transfer.maxretries` (default 8, `tq/manifest.go`)
/// futile round trips before the same refusal is reported, because an SSH
/// grant that is download-only will still be download-only on the eighth
/// try. 405 is reported at once. The reference server,
/// `charmbracelet/git-lfs-transfer`, also answers 405 here
/// (`ErrNotAllowed` → `StatusMethodNotAllowed`, `transfer/processor.go`).
///
/// The condition the table's 403 describes — a *batch* refused because the
/// grant does not permit writing — cannot arise here: over pure SSH the
/// operation is in the exec line, and `gunnar_ssh::command::parse` has
/// already demanded Write before an `upload` session exists at all.
#[error("{0}")]
NotAllowed(String),
/// The client and the server cannot agree on a parameter of the request.
///
/// Today that is only the hash algorithm, and 409 is the code
/// `docs/api/batch.md` assigns it by name: *"409 - The specified hash
/// algorithm disagrees with the server's acceptable options."* It is the
/// only code the specification gives that condition. The reference server
/// puts it in its generic `ErrNotAllowed` bucket and answers 405; the client
/// treats any non-200 batch status the same way (`tq/ssh.go`, `Batch`:
/// `if status != 200 { return errors.New(...) }`, not retriable either way),
/// so saying what the specification says costs nothing.
#[error("{0}")]
Conflict(String),
/// The object is not here.
#[error("{0}")]
NotFound(String),
/// A verb this server does not implement: the three lock commands.
///
/// **501 and not 405, and the difference is measured.** git-lfs's
/// `SearchLocksVerifiable` maps 501 (and 404) to `NewNotImplementedError`,
/// which `lockVerifier::Verify` turns into `disableFor(url)`: locking
/// switches itself off and the push proceeds silently. Any other 4xx takes
/// the branch that prints *"Remote %q does not support the Git LFS locking
/// API. Consider disabling it with: git config lfs.<url>.locksverify false"*
/// on every push. Same refusal, one is quiet and correct.
#[error("{0}")]
NotImplemented(String),
/// The object is larger than this server accepts. **Answered before the
/// bytes arrive** wherever the size is declared up front.
#[error("{0}")]
TooLarge(String),
/// The bytes did not hash to the oid they were sent under, or did not match
/// the declared size. The object is discarded, never committed.
#[error("{0}")]
Corrupt(String),
/// The backing store failed.
#[error("storage: {0}")]
Store(String),
/// The store has no room. `ENOSPC`, `EDQUOT`, or a file the filesystem will
/// not let grow any further.
///
/// **507, which `docs/api/batch.md` lists by name** — *"the server has
/// insufficient storage capacity to complete the request"* — and it is a
/// different fact from [`Error::TooLarge`]'s 413. 413 is *"this object is
/// bigger than I accept"*, a policy the operator set, identical tomorrow.
/// 507 is *"the disk is full"*, which the operator can fix and which says
/// nothing about the object. Answering 500 for it, as this crate did until
/// 2026-08-05, told an operator their server was broken when what they
/// needed was a bigger volume.
///
/// It is **not fatal**, which is the larger half of the fix. A full disk
/// used to surface as [`Error::Io`] straight out of `write_all`, and `Io` is
/// fatal and has no [`Error::final_report`] — so `git lfs push` onto a full
/// volume got a **dropped SSH channel and no status line at all**, the same
/// shape as the refusal the drain used to swallow. Now the session answers
/// `507`, drains, and stays up.
#[error("{0}")]
OutOfSpace(String),
/// **The TENANT has no room left**, as distinct from [`Error::OutOfSpace`]'s
/// *the server* has none. Same 507, and deliberately so; a different string
/// reaching the client, and deliberately so.
///
/// # Why not simply reuse `OutOfSpace`
///
/// Tried first, and it failed on the one thing that matters. `OutOfSpace`'s
/// [`Error::client_message`] is a fixed sentence — *"the server has
/// insufficient storage capacity for this object"* — precisely because its
/// payload is an operator's diagnostic that may name a path, and paths do
/// not go on the wire. That is right for `ENOSPC`. It is wrong here, and
/// twice over:
///
/// 1. It tells the customer the WRONG THING. Their push failed because their
/// plan is spent, which they can fix in a minute with a card; "the server
/// has insufficient storage capacity" says the operator's disk is full,
/// which they cannot fix at all and which invites a support ticket
/// instead of a purchase.
/// 2. It throws away the only sentence that was composed FOR the client.
/// [`crate::Allowance::refusal`] arrives already naming the plan, the cap,
/// what is held and the pay page — assembled by `gunnar-server` from the
/// signed entitlement fact, the same way `gunnar_wire::PackCeiling`
/// carries the pack door's refusal to where the bytes are.
///
/// So: one status code, because from the client's side of the wire "there is
/// no room for this" is one fact and git-lfs should treat it identically;
/// two variants, because what the human reads afterwards is a different
/// sentence with a different remedy. `client_message` passes this one
/// through verbatim — it contains nothing the server composed for itself.
#[error("{0}")]
NoRoomLeft(String),
/// A refusal the server could not resynchronise the stream after.
///
/// # Why the refusal is not simply lost
///
/// `put-object`'s refusals are decided from the arguments, while the client
/// is already writing the object. To stay in sync the server drains the
/// payload it just refused, and that drain is bounded
/// ([`Limits::max_drain_bytes`](crate::Limits::max_drain_bytes), two
/// chunks). The drain used to be spelled `self.drain_data_section(input)?`,
/// so once the client sent past the budget the drain's own `Framing` error
/// **replaced** the refusal — and `Framing` is fatal, so the session ended
/// having written no status line at all.
///
/// The budget is 128 KiB and the quota is 8 GiB, so *every* refusal of an
/// object big enough to be worth refusing took that path: `git lfs push` of
/// a 9 GiB file got a dropped SSH channel where a `413` and its message
/// were already composed. Only the smallest possible case survived, which is
/// the only case `tests/quota.rs` sent — one chunk, exactly under the
/// budget, with a comment saying it was two.
///
/// So the two facts are kept apart: the refusal is what the client is told,
/// the reason for closing is why the session then ends.
///
/// # A second reason to close, added 2026-09-11 (GUN-017)
///
/// The desync above is one way a refused request must not simply be
/// followed by another. The other is a client that CHARGED the namespace's
/// allowance and then did not deliver what it charged for: `put-object`
/// admits against the declared size before the payload arrives, the ledger
/// is a monotone counter with nothing to give back (deliberately — see
/// `ledger.rs`), and a non-fatal refusal let the same client declare again
/// immediately. So the field is "why the session then ends" rather than
/// "the desync", and `Display` no longer asserts which of the two it was.
#[error("{refusal} (and the session is closing: {closing})")]
RefusedThenClosed {
/// What the request was refused for. This is what the client is told.
refusal: Box<Error>,
/// Why the session cannot simply carry on afterwards. Log only.
closing: Box<Error>,
},
}
impl Error {
/// The failure a **store** operation's `io::Error` becomes.
///
/// One function, called by every backend, so no backend decides for itself
/// whether a full disk is a 500 or a 507 (one implementation). `context`
/// is the operator's half — `"write big.bin"`, `"rename … → …"` — and
/// never reaches the client; [`Error::client_message`] replaces it.
///
/// The three kinds that mean *no room* are separated from every other I/O
/// failure. They are all stable since Rust 1.83 and the crate's MSRV is 1.85:
///
/// * `StorageFull` — `ENOSPC`, the volume is full;
/// * `QuotaExceeded` — `EDQUOT`, this user's or project's quota is;
/// * `FileTooLarge` — `EFBIG`, past what the filesystem will let one file be.
///
/// Everything else — a permissions problem, a failing disk, an unmounted
/// path — stays [`Error::Store`] and 500, because those really are *"this
/// server is broken"*.
pub fn store_io(context: impl std::fmt::Display, err: std::io::Error) -> Error {
use std::io::ErrorKind;
match err.kind() {
ErrorKind::StorageFull | ErrorKind::QuotaExceeded | ErrorKind::FileTooLarge => {
Error::OutOfSpace(format!("{context}: {err}"))
}
_ => Error::Store(format!("{context}: {err}")),
}
}
/// The HTTP status code this failure is reported as.
pub fn status(&self) -> u16 {
match self {
// Fatal ones still have a code, for the log and for the one case
// where a partially-written response is better than none.
Error::Io(_) | Error::Framing(_) => 500,
Error::BadRequest(_) => 400,
Error::NotAllowed(_) => 405,
Error::NotImplemented(_) => 501,
Error::NotFound(_) => 404,
Error::Conflict(_) => 409,
Error::TooLarge(_) => 413,
// One code for two facts, on purpose: git-lfs must treat "there
// is no room for this" the same way whoever ran out of it. See
// `Error::NoRoomLeft`.
Error::OutOfSpace(_) | Error::NoRoomLeft(_) => 507,
// 422 and not 400: the request was well-formed, the *content* was
// wrong. git-lfs's HTTP API uses 422 for exactly this, and the
// client treats 4xx as non-retriable, which is right, since
// resending the same bytes would fail the same way.
Error::Corrupt(_) => 422,
Error::Store(_) => 500,
// The refusal's code, not 500. Which code the client sees is the
// whole point of the variant.
Error::RefusedThenClosed { refusal, .. } => refusal.status(),
}
}
/// Does this failure end the session, or merely the command?
///
/// **Only framing and I/O are fatal.** Everything else is a decision about
/// one command, and a server that dropped the connection on a 404 would
/// make a single missing object fail a whole fetch.
pub fn is_fatal(&self) -> bool {
matches!(
self,
Error::Io(_) | Error::Framing(_) | Error::RefusedThenClosed { .. }
)
}
/// The status line a **fatal** failure still owes the client before the
/// session closes, if it has one.
///
/// `None` for [`Error::Io`] and [`Error::Framing`]: the first means the
/// channel is already gone, and the second means the server no longer knows
/// where a packet boundary is, so anything it writes lands in the middle of
/// something. [`Error::RefusedThenClosed`] is the case where the server knows
/// exactly what it decided and simply cannot keep talking afterwards — and
/// there, saying it is the difference between `413 object … is 9663676416
/// bytes; this server accepts at most 8589934592` and a dropped connection.
pub fn final_report(&self) -> Option<(u16, String)> {
match self {
Error::RefusedThenClosed { refusal, .. } => {
Some((refusal.status(), refusal.client_message()))
}
_ => None,
}
}
/// What the client is told. Never carries a path, a principal or a store
/// detail. The server log keeps those.
pub fn client_message(&self) -> String {
match self {
Error::Io(_) | Error::Framing(_) => "the connection failed".to_string(),
Error::Store(_) => "the object store is unavailable".to_string(),
// The one store failure the client IS told the nature of. It carries
// no path and no principal — "the disk is full" is not a leak, and
// "retrying will not help, tell your administrator" is exactly what
// the person watching the push needs to know.
Error::OutOfSpace(_) => {
"the server has insufficient storage capacity for this object".to_string()
}
// The refusal's words. The desync is the server's problem and
// carries a byte budget the client has no use for.
Error::RefusedThenClosed { refusal, .. } => refusal.client_message(),
other => other.to_string(),
}
}
}
impl From<gunnar_pktline::Error> for Error {
fn from(err: gunnar_pktline::Error) -> Self {
match err {
gunnar_pktline::Error::Io(io) => Error::Io(io),
other => Error::Framing(other.to_string()),
}
}
}
/// This crate's result type.
pub type Result<T> = std::result::Result<T, Error>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_failure_maps_to_a_distinct_and_correct_status() {
assert_eq!(Error::BadRequest("x".into()).status(), 400);
assert_eq!(Error::NotFound("x".into()).status(), 404);
assert_eq!(Error::NotAllowed("x".into()).status(), 405);
assert_eq!(Error::NotImplemented("x".into()).status(), 501);
assert_eq!(Error::TooLarge("x".into()).status(), 413);
assert_eq!(Error::Corrupt("x".into()).status(), 422);
assert_eq!(Error::Store("x".into()).status(), 500);
// The red half: a `status()` returning a constant would pass a test
// that only checked one variant.
assert_ne!(
Error::NotFound("x".into()).status(),
Error::TooLarge("x".into()).status()
);
}
#[test]
fn only_transport_failures_are_fatal() {
assert!(Error::Framing("x".into()).is_fatal());
assert!(Error::Io(std::io::Error::other("x")).is_fatal());
// A 404 that killed the session would fail a whole fetch over one
// absent object.
for survivable in [
Error::BadRequest("x".into()),
Error::NotAllowed("x".into()),
Error::NotImplemented("x".into()),
Error::NotFound("x".into()),
Error::TooLarge("x".into()),
Error::Corrupt("x".into()),
Error::Store("x".into()),
] {
assert!(!survivable.is_fatal(), "{survivable:?} must not be fatal");
}
}
}