1use 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::wire::Problem;
17use tollgate_store::{
18 AllocateError, CreateAccountError, PublishSnapshotError, SetStatusError, StoreError,
19};
20use tollgate_store::{IngestError, MAX_INGEST_BATCH};
21
22#[derive(Debug)]
30pub struct ApiError {
31 pub status: StatusCode,
33 pub code: &'static str,
36 pub title: String,
39 pub generation: Option<Generation>,
41 pub balance_exhaustion: Option<tollgate_core::BalanceExhaustion>,
43 pub balance_shortfall: Option<tollgate_core::BalanceShortfall>,
46}
47
48pub(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
70pub(crate) struct ApiPath<T>(pub T);
72
73pub(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 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 pub fn forbidden() -> Self {
126 Self {
127 status: StatusCode::FORBIDDEN,
128 code: "scope-forbidden",
129 title: "credential does not authorize this control-plane operation".into(),
130 generation: None,
131 balance_exhaustion: None,
132 balance_shortfall: None,
133 }
134 }
135 pub fn not_found(code: &'static str, title: impl Into<String>) -> Self {
137 ApiError {
138 status: StatusCode::NOT_FOUND,
139 code,
140 title: title.into(),
141 generation: None,
142 balance_exhaustion: None,
143 balance_shortfall: None,
144 }
145 }
146
147 pub fn revoked(generation: Generation) -> Self {
151 ApiError {
152 status: StatusCode::GONE,
153 code: "revoked-principal",
154 title: "snapshot revoked".to_string(),
155 generation: Some(generation),
156 balance_exhaustion: None,
157 balance_shortfall: None,
158 }
159 }
160
161 pub fn bad_request(code: &'static str, title: impl Into<String>) -> Self {
163 ApiError {
164 status: StatusCode::BAD_REQUEST,
165 code,
166 title: title.into(),
167 generation: None,
168 balance_exhaustion: None,
169 balance_shortfall: None,
170 }
171 }
172
173 pub fn not_implemented(code: &'static str, title: impl Into<String>) -> Self {
177 ApiError {
178 status: StatusCode::NOT_IMPLEMENTED,
179 code,
180 title: title.into(),
181 generation: None,
182 balance_exhaustion: None,
183 balance_shortfall: None,
184 }
185 }
186}
187
188impl From<JsonRejection> for ApiError {
189 fn from(error: JsonRejection) -> Self {
190 let status = error.into_response().status();
191 if status == StatusCode::PAYLOAD_TOO_LARGE {
202 return ApiError {
203 status,
204 code: "batch-too-large",
205 title: format!(
206 "request body exceeds this endpoint's limit; \
207 usage batches are capped at {MAX_INGEST_BATCH} events"
208 ),
209 generation: None,
210 balance_exhaustion: None,
211 balance_shortfall: None,
212 };
213 }
214 ApiError {
215 status,
216 code: "invalid-json",
217 title: "request body is not valid JSON for this endpoint".to_string(),
218 generation: None,
219 balance_exhaustion: None,
220 balance_shortfall: None,
221 }
222 }
223}
224
225impl From<PathRejection> for ApiError {
226 fn from(error: PathRejection) -> Self {
227 let status = error.into_response().status();
228 ApiError {
229 status,
230 code: "invalid-id",
231 title: "path identifier must be exactly 32 lowercase hexadecimal digits".to_string(),
232 generation: None,
233 balance_exhaustion: None,
234 balance_shortfall: None,
235 }
236 }
237}
238
239impl From<AllocateError> for ApiError {
240 fn from(e: AllocateError) -> Self {
241 let balance_exhaustion = match &e {
242 AllocateError::BalanceExhausted(evidence) => Some(*evidence),
243 _ => None,
244 };
245 let balance_shortfall = match &e {
246 AllocateError::BalanceInsufficient(evidence) => Some(*evidence),
247 _ => None,
248 };
249 let (status, code) = match e {
250 AllocateError::UnknownAccount => (StatusCode::NOT_FOUND, "unknown-account"),
251 AllocateError::AccountInactive => (StatusCode::CONFLICT, "account-inactive"),
252 AllocateError::InsufficientBalance | AllocateError::BalanceInsufficient(_) => {
255 (StatusCode::CONFLICT, "insufficient-balance")
256 }
257 AllocateError::BalanceExhausted(_) => (StatusCode::CONFLICT, "balance-exhausted"),
258 AllocateError::BalanceOverflow => {
259 (StatusCode::UNPROCESSABLE_ENTITY, "balance-overflow")
260 }
261 AllocateError::InvalidTtl => (StatusCode::UNPROCESSABLE_ENTITY, "invalid-ttl"),
262 AllocateError::UnknownLease => (StatusCode::NOT_FOUND, "unknown-lease"),
263 AllocateError::Fenced => (StatusCode::CONFLICT, "fenced"),
264 AllocateError::LeaseNotActive => (StatusCode::CONFLICT, "lease-not-active"),
265 AllocateError::InvalidRelease => (StatusCode::UNPROCESSABLE_ENTITY, "invalid-release"),
266 AllocateError::Storage(inner) => return ApiError::from(inner),
267 };
268 ApiError {
269 status,
270 code,
271 title: e.to_string(),
272 generation: None,
273 balance_exhaustion,
274 balance_shortfall,
275 }
276 }
277}
278
279impl From<CreateAccountError> for ApiError {
280 fn from(e: CreateAccountError) -> Self {
281 match e {
282 CreateAccountError::AlreadyExists => ApiError {
283 status: StatusCode::CONFLICT,
284 code: "account-exists",
285 title: "account already exists".to_string(),
286 generation: None,
287 balance_exhaustion: None,
288 balance_shortfall: None,
289 },
290 CreateAccountError::Storage(inner) => ApiError::from(inner),
291 }
292 }
293}
294
295impl From<tollgate_store::KeyError> for ApiError {
296 fn from(e: tollgate_store::KeyError) -> Self {
297 use tollgate_store::KeyError;
298 let (status, code) = match &e {
299 KeyError::UnknownAccount => (StatusCode::NOT_FOUND, "unknown-account"),
300 KeyError::UnknownKey => (StatusCode::NOT_FOUND, "unknown-credential"),
301 KeyError::AlreadyExists => (StatusCode::CONFLICT, "credential-exists"),
306 KeyError::ActiveKeyLimit { .. } => (StatusCode::CONFLICT, "active-key-limit"),
310 KeyError::Storage(inner) => return inner.clone().into(),
311 };
312 ApiError {
313 status,
314 code,
315 title: e.to_string(),
316 generation: None,
317 balance_exhaustion: None,
318 balance_shortfall: None,
319 }
320 }
321}
322
323impl From<tollgate_store::KeySnapshotError> for ApiError {
324 fn from(e: tollgate_store::KeySnapshotError) -> Self {
325 use tollgate_store::KeySnapshotError;
326 let (status, code) = match &e {
327 KeySnapshotError::UnknownCredential => (StatusCode::NOT_FOUND, "unknown-credential"),
329 KeySnapshotError::Retired { .. } => (StatusCode::CONFLICT, "credential-retired"),
332 KeySnapshotError::Publish(inner) => return inner.clone().into(),
333 KeySnapshotError::Storage(inner) => return inner.clone().into(),
334 };
335 ApiError {
336 status,
337 code,
338 title: e.to_string(),
339 generation: None,
340 balance_exhaustion: None,
341 balance_shortfall: None,
342 }
343 }
344}
345
346impl From<tollgate_store::BudgetError> for ApiError {
347 fn from(e: tollgate_store::BudgetError) -> Self {
348 match e {
349 tollgate_store::BudgetError::UnknownAccount => ApiError {
350 status: StatusCode::NOT_FOUND,
351 code: "unknown-account",
352 title: e.to_string(),
353 generation: None,
354 balance_exhaustion: None,
355 balance_shortfall: None,
356 },
357 tollgate_store::BudgetError::Storage(inner) => inner.into(),
358 }
359 }
360}
361
362impl From<SetStatusError> for ApiError {
363 fn from(e: SetStatusError) -> Self {
364 match e {
365 SetStatusError::UnknownAccount => ApiError {
366 status: StatusCode::NOT_FOUND,
367 code: "unknown-account",
368 title: e.to_string(),
369 generation: None,
370 balance_exhaustion: None,
371 balance_shortfall: None,
372 },
373 SetStatusError::AccountClosed => ApiError {
377 status: StatusCode::CONFLICT,
378 code: "account-closed",
379 title: e.to_string(),
380 generation: None,
381 balance_exhaustion: None,
382 balance_shortfall: None,
383 },
384 SetStatusError::Storage(inner) => ApiError::from(inner),
385 }
386 }
387}
388
389impl From<PublishSnapshotError> for ApiError {
390 fn from(e: PublishSnapshotError) -> Self {
391 match e {
392 PublishSnapshotError::CredentialMismatch { .. } => ApiError {
393 status: StatusCode::UNPROCESSABLE_ENTITY,
394 code: "invalid-credential-binding",
395 title: e.to_string(),
396 generation: None,
397 balance_exhaustion: None,
398 balance_shortfall: None,
399 },
400 PublishSnapshotError::StatusMismatch { .. } => ApiError {
401 status: StatusCode::CONFLICT,
402 code: "snapshot-status-mismatch",
403 title: e.to_string(),
404 generation: None,
405 balance_exhaustion: None,
406 balance_shortfall: None,
407 },
408 PublishSnapshotError::CapacityClassMismatch { .. } => ApiError {
413 status: StatusCode::CONFLICT,
414 code: "snapshot-capacity-class-mismatch",
415 title: e.to_string(),
416 generation: None,
417 balance_exhaustion: None,
418 balance_shortfall: None,
419 },
420 PublishSnapshotError::Storage(inner) => ApiError::from(inner),
421 }
422 }
423}
424
425impl From<StoreError> for ApiError {
426 fn from(_: StoreError) -> Self {
427 ApiError {
428 status: StatusCode::SERVICE_UNAVAILABLE,
429 code: "storage",
430 title: "backend unavailable".into(),
433 generation: None,
434 balance_exhaustion: None,
435 balance_shortfall: None,
436 }
437 }
438}
439
440impl From<IngestError> for ApiError {
441 fn from(error: IngestError) -> Self {
442 match error {
443 IngestError::Unavailable(e) => ApiError::from(e),
446 IngestError::Refused(_) => ApiError {
453 status: StatusCode::UNPROCESSABLE_ENTITY,
454 code: "usage-refused",
455 title: "usage batch refused".into(),
456 generation: None,
457 balance_exhaustion: None,
458 balance_shortfall: None,
459 },
460 }
461 }
462}
463
464impl From<SnapshotValidationError> for ApiError {
465 fn from(error: SnapshotValidationError) -> Self {
466 ApiError {
467 status: StatusCode::UNPROCESSABLE_ENTITY,
468 code: "invalid-snapshot-limits",
469 title: error.to_string(),
470 generation: None,
471 balance_exhaustion: None,
472 balance_shortfall: None,
473 }
474 }
475}
476
477impl IntoResponse for ApiError {
478 fn into_response(self) -> Response {
479 self.render(|| diagnostic_id(getrandom::fill))
480 }
481}
482
483#[derive(Clone)]
486struct HttpFailure {
487 code: &'static str,
488 error_id: Option<String>,
489}
490
491fn diagnostic_id(fill: impl FnOnce(&mut [u8]) -> Result<(), getrandom::Error>) -> Option<String> {
492 let mut bytes = [0u8; 16];
493 fill(&mut bytes).ok()?;
494 Some(tollgate_core::RequestId(u128::from_be_bytes(bytes)).to_string())
495}
496
497impl ApiError {
498 fn render(self, new_id: impl FnOnce() -> Option<String>) -> Response {
499 let unauthorized = self.status == StatusCode::UNAUTHORIZED;
500 let failure =
501 (self.status.is_server_error() || self.code == "usage-refused").then(|| HttpFailure {
502 code: self.code,
503 error_id: new_id(),
504 });
505 let problem = Problem {
506 status: self.status.as_u16(),
507 code: self.code.to_string(),
508 title: self.title,
509 generation: self.generation,
510 balance_exhaustion: self.balance_exhaustion,
511 balance_shortfall: self.balance_shortfall,
512 };
513 #[derive(serde::Serialize)]
516 struct DiagnosticProblem {
517 #[serde(flatten)]
518 problem: Problem,
519 #[serde(skip_serializing_if = "Option::is_none")]
520 error_id: Option<String>,
521 }
522 let body = DiagnosticProblem {
523 problem,
524 error_id: failure
525 .as_ref()
526 .and_then(|failure| failure.error_id.clone()),
527 };
528 let mut response = (self.status, Json(body)).into_response();
529 if let Some(failure) = failure {
530 response.extensions_mut().insert(failure);
531 }
532 response.headers_mut().insert(
533 axum::http::header::CONTENT_TYPE,
534 axum::http::HeaderValue::from_static("application/problem+json"),
535 );
536 if unauthorized {
537 response.headers_mut().insert(
538 axum::http::header::WWW_AUTHENTICATE,
539 axum::http::HeaderValue::from_static("Bearer realm=\"tollgate-control\""),
540 );
541 }
542 response
543 }
544}
545
546pub(crate) async fn report_http_failure(request: Request, next: Next) -> Response {
549 let route = request.extensions().get::<MatchedPath>().cloned();
550 let response = next.run(request).await;
551 if let Some(failure) = response.extensions().get::<HttpFailure>() {
552 tracing::warn!(
553 target: "tollgate::diagnostics",
554 route = route.as_ref().map(MatchedPath::as_str).unwrap_or("unmatched"),
555 code = failure.code,
556 status = response.status().as_u16(),
557 error_id = failure.error_id.as_deref(),
558 error_id_unavailable = failure.error_id.is_none(),
559 "control-plane operation failed; consult backend health and retained operational records"
560 );
561 }
562 response
563}
564
565#[cfg(test)]
566mod tests {
567 use super::*;
568
569 #[test]
570 fn diagnostic_identifiers_use_all_entropy_and_surface_entropy_failure() {
571 let id = diagnostic_id(|bytes| {
572 bytes.copy_from_slice(&0x00112233445566778899aabbccddeeff_u128.to_be_bytes());
573 Ok(())
574 });
575 assert_eq!(id.as_deref(), Some("00112233445566778899aabbccddeeff"));
576 assert!(diagnostic_id(|_| Err(getrandom::Error::UNSUPPORTED)).is_none());
577 }
578
579 #[tokio::test]
580 async fn entropy_failure_preserves_the_error_without_inventing_an_identifier() {
581 use http_body_util::BodyExt;
582 let response = ApiError::from(StoreError("fixture-secret-70".into())).render(|| None);
583 assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
584 let failure = response.extensions().get::<HttpFailure>().unwrap();
585 assert_eq!(failure.code, "storage");
586 assert!(failure.error_id.is_none());
587 let body = response.into_body().collect().await.unwrap().to_bytes();
588 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
589 assert_eq!(json["title"], "backend unavailable");
590 assert!(json.get("error_id").is_none());
591 }
592}