Skip to main content

tollgate_server/
error.rs

1//! RFC-7807 `application/problem+json` errors with stable machine codes.
2//!
3//! The `code` strings are wire contract: `tollgate-client`'s HTTP transport maps
4//! them back to `AllocateError` variants. Change one and the loopback
5//! correctness suite fails.
6
7use axum::extract::rejection::{JsonRejection, PathRejection};
8use axum::extract::{FromRequest, FromRequestParts, Json, MatchedPath, Path, Request};
9use axum::http::StatusCode;
10use axum::http::request::Parts;
11use axum::middleware::Next;
12use axum::response::{IntoResponse, Response};
13use serde::de::DeserializeOwned;
14
15use tollgate_core::{Generation, SnapshotValidationError};
16use tollgate_store::IngestError;
17use tollgate_store::wire::Problem;
18use tollgate_store::{
19    AllocateError, CreateAccountError, PublishSnapshotError, SetStatusError, StoreError,
20};
21
22/// A refused control-plane request, rendered as an RFC-7807
23/// `application/problem+json` body in the [`Problem`] shape.
24///
25/// A `401` also carries a `Bearer` challenge. A 5xx or `usage-refused`
26/// response adds an optional `error_id` that matches a warning on the
27/// `tollgate::diagnostics` target. Converting a [`StoreError`] never exposes
28/// its text, in the body or in `Debug` output (INVARIANTS.md 37).
29#[derive(Debug)]
30pub struct ApiError {
31    /// The HTTP status, also sent as the body's `status`.
32    pub status: StatusCode,
33    /// The stable machine code clients classify the refusal by. Part of the
34    /// wire contract.
35    pub code: &'static str,
36    /// A short public description. Not a contract; classify by `status` and
37    /// `code`.
38    pub title: String,
39    /// The tombstone's generation on a `revoked-principal` refusal.
40    pub generation: Option<Generation>,
41    /// The allocator's evidence on a `balance-exhausted` refusal.
42    pub balance_exhaustion: Option<tollgate_core::BalanceExhaustion>,
43    /// The remaining funding on an `insufficient-balance` refusal, when the
44    /// allocator attested it.
45    pub balance_shortfall: Option<tollgate_core::BalanceShortfall>,
46}
47
48/// JSON input whose extractor failures stay inside the RFC-7807 contract.
49///
50/// Axum's default rejection is plain text. Keeping the wrapper at the service
51/// boundary makes malformed identifiers and every same-pattern body failure
52/// structured without relying on each handler to remember an error mapping.
53pub(crate) struct ApiJson<T>(pub T);
54
55impl<T, S> FromRequest<S> for ApiJson<T>
56where
57    T: DeserializeOwned,
58    S: Send + Sync,
59{
60    type Rejection = ApiError;
61
62    async fn from_request(request: Request, state: &S) -> Result<Self, Self::Rejection> {
63        Json::<T>::from_request(request, state)
64            .await
65            .map(|Json(value)| Self(value))
66            .map_err(ApiError::from)
67    }
68}
69
70/// Path input whose parse failures stay distinct from a legitimate 404.
71pub(crate) struct ApiPath<T>(pub T);
72
73/// Query decoding is subject to the same structured external-input contract.
74pub(crate) struct ApiQuery<T>(pub T);
75impl<T, S> FromRequestParts<S> for ApiQuery<T>
76where
77    T: DeserializeOwned + Send,
78    S: Send + Sync,
79{
80    type Rejection = ApiError;
81    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
82        axum::extract::Query::<T>::from_request_parts(parts, state)
83            .await
84            .map(|axum::extract::Query(value)| Self(value))
85            .map_err(|_| {
86                ApiError::bad_request(
87                    "invalid-query",
88                    "query parameters are malformed or unsupported",
89                )
90            })
91    }
92}
93
94impl<T, S> FromRequestParts<S> for ApiPath<T>
95where
96    T: DeserializeOwned + Send,
97    S: Send + Sync,
98{
99    type Rejection = ApiError;
100
101    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
102        Path::<T>::from_request_parts(parts, state)
103            .await
104            .map(|Path(value)| Self(value))
105            .map_err(ApiError::from)
106    }
107}
108
109impl ApiError {
110    /// `401 authentication-required`: credentials are missing, invalid,
111    /// expired or conflicting. The response carries a `Bearer` challenge.
112    pub fn unauthorized() -> Self {
113        Self {
114            status: StatusCode::UNAUTHORIZED,
115            code: "authentication-required",
116            title: "valid control-plane credentials required".into(),
117            generation: None,
118            balance_exhaustion: None,
119            balance_shortfall: None,
120        }
121    }
122
123    /// A `403` naming why an authenticated identity may not do this (#39):
124    /// `scope-forbidden` for an argument its role may not send,
125    /// `account-not-provisioned` or `operator-hold` for an account outside a
126    /// provisioner's reach.
127    pub(crate) fn refused_scope(code: &'static str, title: impl Into<String>) -> Self {
128        Self {
129            status: StatusCode::FORBIDDEN,
130            code,
131            title: title.into(),
132            generation: None,
133            balance_exhaustion: None,
134            balance_shortfall: None,
135        }
136    }
137
138    /// `403 scope-forbidden`: the credential is valid, but its identity lacks
139    /// the role this route requires.
140    pub fn forbidden() -> Self {
141        Self {
142            status: StatusCode::FORBIDDEN,
143            code: "scope-forbidden",
144            title: "credential does not authorize this control-plane operation".into(),
145            generation: None,
146            balance_exhaustion: None,
147            balance_shortfall: None,
148        }
149    }
150    /// `404` with the given machine code and title.
151    pub fn not_found(code: &'static str, title: impl Into<String>) -> Self {
152        ApiError {
153            status: StatusCode::NOT_FOUND,
154            code,
155            title: title.into(),
156            generation: None,
157            balance_exhaustion: None,
158            balance_shortfall: None,
159        }
160    }
161
162    /// `410 revoked-principal`: the principal's snapshot is a tombstone.
163    /// Carries the tombstone's `generation` so a client can order it against
164    /// the positive snapshots it holds (INVARIANTS.md 15).
165    pub fn revoked(generation: Generation) -> Self {
166        ApiError {
167            status: StatusCode::GONE,
168            code: "revoked-principal",
169            title: "snapshot revoked".to_string(),
170            generation: Some(generation),
171            balance_exhaustion: None,
172            balance_shortfall: None,
173        }
174    }
175
176    /// `400` with the given machine code and title.
177    pub fn bad_request(code: &'static str, title: impl Into<String>) -> Self {
178        ApiError {
179            status: StatusCode::BAD_REQUEST,
180            code,
181            title: title.into(),
182            generation: None,
183            balance_exhaustion: None,
184            balance_shortfall: None,
185        }
186    }
187
188    /// The backend cannot answer this at all, as opposed to answering
189    /// "nothing" — a distinction a caller must be able to act on differently
190    /// (GL-48).
191    pub fn not_implemented(code: &'static str, title: impl Into<String>) -> Self {
192        ApiError {
193            status: StatusCode::NOT_IMPLEMENTED,
194            code,
195            title: title.into(),
196            generation: None,
197            balance_exhaustion: None,
198            balance_shortfall: None,
199        }
200    }
201}
202
203impl From<JsonRejection> for ApiError {
204    fn from(error: JsonRejection) -> Self {
205        let status = error.into_response().status();
206        // A body over the endpoint's limit is not malformed JSON, and saying
207        // so sent a client looking for a syntax error in a payload it had
208        // serialised correctly. The two are told apart by status rather than
209        // by matching axum's rejection variants, because the nesting that
210        // produces a 413 is an internal detail of the extractor and the status
211        // is the part of that behaviour axum documents (GL-61).
212        //
213        // Distinct codes matter beyond the message: a client can retry a
214        // transient failure, and must never retry this one unchanged — an
215        // oversized batch is refused identically forever.
216        if status == StatusCode::PAYLOAD_TOO_LARGE {
217            return ApiError {
218                status,
219                code: "batch-too-large",
220                title: "request body exceeds this endpoint's limit".into(),
221                generation: None,
222                balance_exhaustion: None,
223                balance_shortfall: None,
224            };
225        }
226        ApiError {
227            status,
228            code: "invalid-json",
229            title: "request body is not valid JSON for this endpoint".to_string(),
230            generation: None,
231            balance_exhaustion: None,
232            balance_shortfall: None,
233        }
234    }
235}
236
237impl From<PathRejection> for ApiError {
238    fn from(error: PathRejection) -> Self {
239        let status = error.into_response().status();
240        ApiError {
241            status,
242            code: "invalid-id",
243            title: "path identifier must be exactly 32 lowercase hexadecimal digits".to_string(),
244            generation: None,
245            balance_exhaustion: None,
246            balance_shortfall: None,
247        }
248    }
249}
250
251impl From<AllocateError> for ApiError {
252    fn from(e: AllocateError) -> Self {
253        let balance_exhaustion = match &e {
254            AllocateError::BalanceExhausted(evidence) => Some(*evidence),
255            _ => None,
256        };
257        let balance_shortfall = match &e {
258            AllocateError::BalanceInsufficient(evidence) => Some(*evidence),
259            _ => None,
260        };
261        let (status, code) = match e {
262            AllocateError::UnknownAccount => (StatusCode::NOT_FOUND, "unknown-account"),
263            AllocateError::AccountInactive => (StatusCode::CONFLICT, "account-inactive"),
264            // Attested and unattested shortfalls share the code, so a client
265            // that predates the extension reads the refusal it always did.
266            AllocateError::InsufficientBalance | AllocateError::BalanceInsufficient(_) => {
267                (StatusCode::CONFLICT, "insufficient-balance")
268            }
269            AllocateError::BalanceExhausted(_) => (StatusCode::CONFLICT, "balance-exhausted"),
270            AllocateError::BalanceOverflow => {
271                (StatusCode::UNPROCESSABLE_ENTITY, "balance-overflow")
272            }
273            AllocateError::InvalidTtl => (StatusCode::UNPROCESSABLE_ENTITY, "invalid-ttl"),
274            AllocateError::UnknownLease => (StatusCode::NOT_FOUND, "unknown-lease"),
275            AllocateError::Fenced => (StatusCode::CONFLICT, "fenced"),
276            AllocateError::LeaseNotActive => (StatusCode::CONFLICT, "lease-not-active"),
277            AllocateError::InvalidRelease => (StatusCode::UNPROCESSABLE_ENTITY, "invalid-release"),
278            AllocateError::Storage(inner) => return ApiError::from(inner),
279        };
280        ApiError {
281            status,
282            code,
283            title: e.to_string(),
284            generation: None,
285            balance_exhaustion,
286            balance_shortfall,
287        }
288    }
289}
290
291impl From<CreateAccountError> for ApiError {
292    fn from(e: CreateAccountError) -> Self {
293        match e {
294            CreateAccountError::AlreadyExists => ApiError {
295                status: StatusCode::CONFLICT,
296                code: "account-exists",
297                title: "account already exists".to_string(),
298                generation: None,
299                balance_exhaustion: None,
300                balance_shortfall: None,
301            },
302            CreateAccountError::Storage(inner) => ApiError::from(inner),
303        }
304    }
305}
306
307impl From<tollgate_store::KeyError> for ApiError {
308    fn from(e: tollgate_store::KeyError) -> Self {
309        use tollgate_store::KeyError;
310        let (status, code) = match &e {
311            KeyError::UnknownAccount => (StatusCode::NOT_FOUND, "unknown-account"),
312            KeyError::UnknownKey => (StatusCode::NOT_FOUND, "unknown-credential"),
313            // 409, not 422: the request is well-formed and the caller is not
314            // at fault for asking. It is also the retry answer — a caller that
315            // lost the response and resent the same `key_id` is being told its
316            // first call worked, which is the truth and discloses nothing.
317            KeyError::AlreadyExists => (StatusCode::CONFLICT, "credential-exists"),
318            // 409 for the same reason: nothing about the request is malformed.
319            // The account is at the bound it was asked to respect, and the
320            // remedy is to revoke a credential, not to rephrase the call.
321            KeyError::ActiveKeyLimit { .. } => (StatusCode::CONFLICT, "active-key-limit"),
322            KeyError::Storage(inner) => return inner.clone().into(),
323        };
324        ApiError {
325            status,
326            code,
327            title: e.to_string(),
328            generation: None,
329            balance_exhaustion: None,
330            balance_shortfall: None,
331        }
332    }
333}
334
335impl From<tollgate_store::KeySnapshotError> for ApiError {
336    fn from(e: tollgate_store::KeySnapshotError) -> Self {
337        use tollgate_store::KeySnapshotError;
338        let (status, code) = match &e {
339            // The answer revocation gives for a foreign or unknown key.
340            KeySnapshotError::UnknownCredential => (StatusCode::NOT_FOUND, "unknown-credential"),
341            // 409: well-formed, but the credential is terminally retired and is
342            // never granted positive authorization again (INVARIANTS.md GL-27).
343            KeySnapshotError::Retired { .. } => (StatusCode::CONFLICT, "credential-retired"),
344            KeySnapshotError::Publish(inner) => return inner.clone().into(),
345            KeySnapshotError::Storage(inner) => return inner.clone().into(),
346        };
347        ApiError {
348            status,
349            code,
350            title: e.to_string(),
351            generation: None,
352            balance_exhaustion: None,
353            balance_shortfall: None,
354        }
355    }
356}
357
358impl From<tollgate_store::BudgetError> for ApiError {
359    fn from(e: tollgate_store::BudgetError) -> Self {
360        match e {
361            tollgate_store::BudgetError::UnknownAccount => ApiError {
362                status: StatusCode::NOT_FOUND,
363                code: "unknown-account",
364                title: e.to_string(),
365                generation: None,
366                balance_exhaustion: None,
367                balance_shortfall: None,
368            },
369            tollgate_store::BudgetError::Storage(inner) => inner.into(),
370        }
371    }
372}
373
374impl From<SetStatusError> for ApiError {
375    fn from(e: SetStatusError) -> Self {
376        match e {
377            SetStatusError::UnknownAccount => ApiError {
378                status: StatusCode::NOT_FOUND,
379                code: "unknown-account",
380                title: e.to_string(),
381                generation: None,
382                balance_exhaustion: None,
383                balance_shortfall: None,
384            },
385            // 409, not 422: the request is well-formed and the operator is
386            // not at fault for asking. The account is simply in a state no
387            // transition leaves (INVARIANTS.md GL-22).
388            SetStatusError::AccountClosed => ApiError {
389                status: StatusCode::CONFLICT,
390                code: "account-closed",
391                title: e.to_string(),
392                generation: None,
393                balance_exhaustion: None,
394                balance_shortfall: None,
395            },
396            // 403, not 409: a provisioner is outside its scope, and retrying
397            // cannot help. An operator can act where a provisioner cannot (#39).
398            SetStatusError::NotProvisioned => {
399                ApiError::refused_scope("account-not-provisioned", e.to_string())
400            }
401            SetStatusError::OperatorHold => ApiError::refused_scope("operator-hold", e.to_string()),
402            SetStatusError::Storage(inner) => ApiError::from(inner),
403        }
404    }
405}
406
407impl From<PublishSnapshotError> for ApiError {
408    fn from(e: PublishSnapshotError) -> Self {
409        match e {
410            PublishSnapshotError::CredentialMismatch { .. } => ApiError {
411                status: StatusCode::UNPROCESSABLE_ENTITY,
412                code: "invalid-credential-binding",
413                title: e.to_string(),
414                generation: None,
415                balance_exhaustion: None,
416                balance_shortfall: None,
417            },
418            PublishSnapshotError::StatusMismatch { .. } => ApiError {
419                status: StatusCode::CONFLICT,
420                code: "snapshot-status-mismatch",
421                title: e.to_string(),
422                generation: None,
423                balance_exhaustion: None,
424                balance_shortfall: None,
425            },
426            // 409 for the reason the status mismatch is: the request is
427            // well-formed and the operator is not at fault — the account
428            // simply owns this fact, and it is changed through its own
429            // endpoint (GL-99).
430            PublishSnapshotError::CapacityClassMismatch { .. } => ApiError {
431                status: StatusCode::CONFLICT,
432                code: "snapshot-capacity-class-mismatch",
433                title: e.to_string(),
434                generation: None,
435                balance_exhaustion: None,
436                balance_shortfall: None,
437            },
438            PublishSnapshotError::Storage(inner) => ApiError::from(inner),
439        }
440    }
441}
442
443impl From<StoreError> for ApiError {
444    fn from(_: StoreError) -> Self {
445        ApiError {
446            status: StatusCode::SERVICE_UNAVAILABLE,
447            code: "storage",
448            // A backend owns arbitrary text, which may include credentials or
449            // private row values. Never format it into a public diagnostic.
450            title: "backend unavailable".into(),
451            generation: None,
452            balance_exhaustion: None,
453            balance_shortfall: None,
454        }
455    }
456}
457
458impl From<IngestError> for ApiError {
459    fn from(error: IngestError) -> Self {
460        match error {
461            // The store could not answer. A client should retry, and 503 is
462            // the status that says so.
463            IngestError::Unavailable(e) => ApiError::from(e),
464            // The store examined this batch and refused it: an accounting
465            // total that cannot absorb these units will not absorb them on a
466            // replay either. 422 rather than 503, so a client can tell a
467            // refusal it must not repeat from an outage it should wait out —
468            // which is the distinction GL-61 is about, made at both ends of the
469            // wire rather than only at the transport.
470            IngestError::Refused(_) => ApiError {
471                status: StatusCode::UNPROCESSABLE_ENTITY,
472                code: "usage-refused",
473                title: "usage batch refused".into(),
474                generation: None,
475                balance_exhaustion: None,
476                balance_shortfall: None,
477            },
478        }
479    }
480}
481
482impl From<SnapshotValidationError> for ApiError {
483    fn from(error: SnapshotValidationError) -> Self {
484        ApiError {
485            status: StatusCode::UNPROCESSABLE_ENTITY,
486            code: "invalid-snapshot-limits",
487            title: error.to_string(),
488            generation: None,
489            balance_exhaustion: None,
490            balance_shortfall: None,
491        }
492    }
493}
494
495impl IntoResponse for ApiError {
496    fn into_response(self) -> Response {
497        self.render(|| diagnostic_id(getrandom::fill))
498    }
499}
500
501/// Only generated identifiers and static machine codes cross into diagnostics.
502/// The middleware receives this marker, never a backend error or response text.
503#[derive(Clone)]
504struct HttpFailure {
505    code: &'static str,
506    error_id: Option<String>,
507}
508
509fn diagnostic_id(fill: impl FnOnce(&mut [u8]) -> Result<(), getrandom::Error>) -> Option<String> {
510    let mut bytes = [0u8; 16];
511    fill(&mut bytes).ok()?;
512    Some(tollgate_core::RequestId(u128::from_be_bytes(bytes)).to_string())
513}
514
515impl ApiError {
516    fn render(self, new_id: impl FnOnce() -> Option<String>) -> Response {
517        let unauthorized = self.status == StatusCode::UNAUTHORIZED;
518        let failure =
519            (self.status.is_server_error() || self.code == "usage-refused").then(|| HttpFailure {
520                code: self.code,
521                error_id: new_id(),
522            });
523        let problem = Problem {
524            status: self.status.as_u16(),
525            code: self.code.to_string(),
526            title: self.title,
527            generation: self.generation,
528            balance_exhaustion: self.balance_exhaustion,
529            balance_shortfall: self.balance_shortfall,
530        };
531        // Keep the public Problem Rust shape intact. This optional JSON
532        // extension is ignored by existing clients and carries no authority.
533        #[derive(serde::Serialize)]
534        struct DiagnosticProblem {
535            #[serde(flatten)]
536            problem: Problem,
537            #[serde(skip_serializing_if = "Option::is_none")]
538            error_id: Option<String>,
539        }
540        let body = DiagnosticProblem {
541            problem,
542            error_id: failure
543                .as_ref()
544                .and_then(|failure| failure.error_id.clone()),
545        };
546        let mut response = (self.status, Json(body)).into_response();
547        if let Some(failure) = failure {
548            response.extensions_mut().insert(failure);
549        }
550        response.headers_mut().insert(
551            axum::http::header::CONTENT_TYPE,
552            axum::http::HeaderValue::from_static("application/problem+json"),
553        );
554        if unauthorized {
555            response.headers_mut().insert(
556                axum::http::header::WWW_AUTHENTICATE,
557                axum::http::HeaderValue::from_static("Bearer realm=\"tollgate-control\""),
558            );
559        }
560        response
561    }
562}
563
564/// The router owns failure reporting, including static route context. Raw
565/// paths, queries, headers, request bodies and backend text are never logged.
566pub(crate) async fn report_http_failure(request: Request, next: Next) -> Response {
567    let route = request.extensions().get::<MatchedPath>().cloned();
568    let response = next.run(request).await;
569    if let Some(failure) = response.extensions().get::<HttpFailure>() {
570        tracing::warn!(
571            target: "tollgate::diagnostics",
572            route = route.as_ref().map(MatchedPath::as_str).unwrap_or("unmatched"),
573            code = failure.code,
574            status = response.status().as_u16(),
575            error_id = failure.error_id.as_deref(),
576            error_id_unavailable = failure.error_id.is_none(),
577            "control-plane operation failed; consult backend health and retained operational records"
578        );
579    }
580    response
581}
582
583#[cfg(test)]
584mod tests {
585    use super::*;
586
587    #[test]
588    fn diagnostic_identifiers_use_all_entropy_and_surface_entropy_failure() {
589        let id = diagnostic_id(|bytes| {
590            bytes.copy_from_slice(&0x00112233445566778899aabbccddeeff_u128.to_be_bytes());
591            Ok(())
592        });
593        assert_eq!(id.as_deref(), Some("00112233445566778899aabbccddeeff"));
594        assert!(diagnostic_id(|_| Err(getrandom::Error::UNSUPPORTED)).is_none());
595    }
596
597    #[tokio::test]
598    async fn entropy_failure_preserves_the_error_without_inventing_an_identifier() {
599        use http_body_util::BodyExt;
600        let response = ApiError::from(StoreError("fixture-secret-70".into())).render(|| None);
601        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
602        let failure = response.extensions().get::<HttpFailure>().unwrap();
603        assert_eq!(failure.code, "storage");
604        assert!(failure.error_id.is_none());
605        let body = response.into_body().collect().await.unwrap().to_bytes();
606        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
607        assert_eq!(json["title"], "backend unavailable");
608        assert!(json.get("error_id").is_none());
609    }
610}