Skip to main content

lfsx_server/
error.rs

1use axum::Json;
2use axum::http::{HeaderValue, StatusCode, header};
3use axum::response::{IntoResponse, Response};
4use serde_json::json;
5
6#[derive(Debug, thiserror::Error)]
7pub enum Error {
8    #[error("object id is not a lowercase hex sha256 digest")]
9    MalformedOid,
10
11    #[error("organisation and repository must be plain names")]
12    MalformedNamespace,
13
14    #[error("content hashes to {actual}, which does not match the declared object id {declared}")]
15    OidMismatch { declared: String, actual: String },
16
17    #[error("content is {actual} bytes, but {declared} were declared")]
18    SizeMismatch { declared: u64, actual: u64 },
19
20    #[error("object exceeds the {limit} byte limit this server accepts")]
21    TooLarge { limit: u64 },
22
23    #[error(
24        "a batch asks about {asked} objects, and this server answers at most {limit} at a time"
25    )]
26    BatchTooLarge { asked: usize, limit: usize },
27
28    #[error("this repository holds {used} bytes of its {limit} byte budget")]
29    OverQuota { used: u64, limit: u64 },
30
31    #[error("this server does not compress objects — set LFSX_COMPRESSION first")]
32    CompressionDisabled,
33
34    #[error("this object is encrypted and this server holds no key — set LFSX_ENCRYPTION_KEY_FILE")]
35    NotDecryptable,
36
37    #[error("this object was encrypted with a key this server does not hold")]
38    UnknownKey,
39
40    #[error("this object failed its integrity check — the bytes on disk are not what was stored")]
41    Tampered,
42
43    #[error("{0}")]
44    Misconfigured(&'static str),
45
46    #[error("{0}")]
47    Unsupported(&'static str),
48
49    #[error("credentials are required for this repository")]
50    Unauthenticated,
51
52    #[error("these credentials do not grant that access to this repository")]
53    Forbidden,
54
55    #[error("the forge could not be reached to check permissions")]
56    Forge,
57
58    // Distinct from `Forge` on purpose. A throttled forge is not a broken one:
59    // it is working, it has said when to come back, and the answer has to carry
60    // that so a client waits instead of spending the next request on the same
61    // exhausted quota.
62    #[error("the forge is rate-limiting this server — retry in {retry_after} seconds")]
63    RateLimited { retry_after: u64 },
64
65    // And distinct from that one, because the two read the same to a client and
66    // mean opposite things to an operator. `RateLimited` is the forge saying it
67    // has had enough of this server. This is the server saying it has had enough
68    // on the forge's behalf, before the forge is given a reason to refuse
69    // everybody at once.
70    #[error("this server is not asking the forge again yet, retry in {retry_after} seconds")]
71    LookupBudgetSpent { retry_after: u64 },
72
73    #[error("lock path must not be empty")]
74    MalformedLockPath,
75
76    #[error("the file is already locked")]
77    LockHeld(Box<crate::locks::Lock>),
78
79    #[error("lock not found")]
80    LockNotFound,
81
82    #[error("object not found")]
83    NotFound,
84
85    #[error("storage failure: {0}")]
86    Storage(#[from] std::io::Error),
87
88    #[error("could not serialise: {0}")]
89    Serialisation(#[from] serde_json::Error),
90}
91
92const CHALLENGE: HeaderValue = HeaderValue::from_static("Basic realm=\"Git LFS\"");
93
94impl Error {
95    fn status(&self) -> StatusCode {
96        match self {
97            Self::MalformedOid
98            | Self::MalformedLockPath
99            | Self::MalformedNamespace
100            | Self::OidMismatch { .. }
101            | Self::SizeMismatch { .. }
102            | Self::BatchTooLarge { .. } => StatusCode::UNPROCESSABLE_ENTITY,
103            Self::TooLarge { .. } => StatusCode::PAYLOAD_TOO_LARGE,
104            Self::OverQuota { .. } => StatusCode::INSUFFICIENT_STORAGE,
105            Self::CompressionDisabled => StatusCode::CONFLICT,
106            // The object is there and the request was fine; this server cannot
107            // serve it, which is a fact about the deployment.
108            Self::NotDecryptable | Self::UnknownKey => StatusCode::INTERNAL_SERVER_ERROR,
109            Self::Tampered => StatusCode::INTERNAL_SERVER_ERROR,
110            Self::Misconfigured(_) => StatusCode::INTERNAL_SERVER_ERROR,
111            Self::Unsupported(_) => StatusCode::NOT_IMPLEMENTED,
112            Self::Unauthenticated => StatusCode::UNAUTHORIZED,
113            Self::Forbidden => StatusCode::FORBIDDEN,
114            Self::LockHeld(_) => StatusCode::CONFLICT,
115            Self::NotFound | Self::LockNotFound => StatusCode::NOT_FOUND,
116            Self::Forge => StatusCode::BAD_GATEWAY,
117            // Not 502: a bad gateway invites an immediate retry, which is the
118            // one thing that must not happen here.
119            Self::RateLimited { .. } | Self::LookupBudgetSpent { .. } => {
120                StatusCode::SERVICE_UNAVAILABLE
121            }
122            Self::Storage(_) | Self::Serialisation(_) => StatusCode::INTERNAL_SERVER_ERROR,
123        }
124    }
125}
126
127impl Error {
128    fn cause(&self) -> &'static str {
129        match self {
130            Self::MalformedOid => "malformed_oid",
131            Self::MalformedNamespace => "malformed_namespace",
132            Self::MalformedLockPath => "malformed_lock_path",
133            Self::OidMismatch { .. } => "oid_mismatch",
134            Self::SizeMismatch { .. } => "size_mismatch",
135            Self::TooLarge { .. } => "too_large",
136            Self::BatchTooLarge { .. } => "batch_too_large",
137            Self::OverQuota { .. } => "over_quota",
138            Self::CompressionDisabled => "compression_disabled",
139            Self::NotDecryptable => "not_decryptable",
140            Self::UnknownKey => "unknown_key",
141            Self::Tampered => "tampered",
142            Self::Misconfigured(_) => "misconfigured",
143            Self::Unsupported(_) => "unsupported",
144            Self::Unauthenticated => "unauthenticated",
145            Self::Forbidden => "forbidden",
146            Self::Forge => "forge_unreachable",
147            // "the forge is throttling us" and "the forge is broken" are
148            // different afternoons, and sharing one label hides which.
149            Self::RateLimited { .. } => "forge_rate_limited",
150            Self::LookupBudgetSpent { .. } => "lookup_budget_spent",
151            Self::LockHeld(_) => "lock_held",
152            Self::LockNotFound => "lock_not_found",
153            Self::NotFound => "not_found",
154            Self::Storage(_) => "storage",
155            Self::Serialisation(_) => "serialisation",
156        }
157    }
158}
159
160impl IntoResponse for Error {
161    fn into_response(self) -> Response {
162        let status = self.status();
163        let cause = crate::metrics::Cause(self.cause());
164
165        if status.is_server_error() {
166            tracing::error!(error = %self, "request failed");
167        }
168
169        if let Self::RateLimited { retry_after } | Self::LookupBudgetSpent { retry_after } = &self {
170            let mut response = (
171                status,
172                [(header::RETRY_AFTER, retry_after.to_string())],
173                Json(json!({ "message": self.to_string() })),
174            )
175                .into_response();
176            response.extensions_mut().insert(cause);
177            return response;
178        }
179
180        if let Self::LockHeld(lock) = &self {
181            let mut response = (
182                status,
183                Json(json!({ "lock": lock, "message": self.to_string() })),
184            )
185                .into_response();
186            response.extensions_mut().insert(cause);
187            return response;
188        }
189
190        let body = Json(json!({ "message": self.to_string() }));
191        let mut response = if status == StatusCode::UNAUTHORIZED {
192            (
193                status,
194                [
195                    (header::WWW_AUTHENTICATE, CHALLENGE),
196                    (
197                        header::HeaderName::from_static("lfs-authenticate"),
198                        CHALLENGE,
199                    ),
200                ],
201                body,
202            )
203                .into_response()
204        } else {
205            (status, body).into_response()
206        };
207
208        response.extensions_mut().insert(cause);
209        response
210    }
211}