1use axum::{
7 Json,
8 http::StatusCode,
9 response::{IntoResponse, Response},
10};
11use serde::Serialize;
12use serde_json::{Value, json};
13use thiserror::Error;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum PipelineErrorSource {
28 Cognify,
30 Memify,
32 Improve,
34 Remember,
37 Sync,
39}
40
41#[derive(Debug, Clone, Serialize)]
46pub struct ValidationDetails {
47 pub detail: Value,
49 pub body: Option<Value>,
51}
52
53#[derive(Debug, Error)]
58pub enum ApiError {
59 #[error("bad request: {0}")]
61 BadRequest(String),
62
63 #[error("unauthorized")]
65 Unauthorized,
66
67 #[error("forbidden: {0}")]
69 Forbidden(String),
70
71 #[error("not found: {0}")]
73 NotFound(String),
74
75 #[error("conflict: {0}")]
77 Conflict(String),
78
79 #[error("validation error")]
81 Validation(ValidationDetails),
82
83 #[error("login bad credentials")]
85 LoginBadCredentials,
86
87 #[error("login user not verified")]
89 LoginUserNotVerified,
90
91 #[error("register user already exists")]
93 RegisterUserAlreadyExists,
94
95 #[error("register invalid password: {0}")]
97 RegisterInvalidPassword(String),
98
99 #[error("reset password bad token")]
101 ResetPasswordBadToken,
102
103 #[error("reset password invalid password: {0}")]
105 ResetPasswordInvalidPassword(String),
106
107 #[error("verify user bad token")]
109 VerifyUserBadToken,
110
111 #[error("verify user already verified")]
113 VerifyUserAlreadyVerified,
114
115 #[error("update user email already exists")]
117 UpdateUserEmailAlreadyExists,
118
119 #[error("update user invalid password: {0}")]
121 UpdateUserInvalidPassword(String),
122
123 #[error("api key error: {0}")]
126 ApiKeyEnvelope(String),
127
128 #[error("pipeline errored ({pipeline_source:?})")]
145 PipelineErrored {
146 pipeline_source: PipelineErrorSource,
147 run_info: serde_json::Value,
150 },
151
152 #[error("teapot: {0}")]
157 Teapot(String),
158
159 #[error("write endpoint error: {error}")]
165 WriteEndpointError {
166 error: String,
167 detail: Option<String>,
168 status: StatusCode,
169 },
170
171 #[error("write envelope error: {0}")]
174 WriteEnvelopeError(String, StatusCode),
175
176 #[error("error message: {0}")]
178 ErrorMessageError(String, StatusCode),
179
180 #[error("error envelope: {0}")]
182 OntologyEnvelope(String, StatusCode),
183
184 #[error("conflict error: {0}")]
192 DeprecatedConflict(String),
193
194 #[error("not implemented: {0}")]
196 NotImplemented(String),
197
198 #[error("service unavailable: {0}")]
205 ServiceUnavailable(String),
206
207 #[error("not implemented stub: {detail}")]
213 NotImplementedStub {
214 code: &'static str,
215 detail: &'static str,
216 },
217
218 #[error("search error: {error}")]
228 SearchError {
229 status: StatusCode,
230 error: String,
231 detail: Option<String>,
232 },
233
234 #[error("recall error")]
242 RecallError {
243 status: StatusCode,
244 body: RecallErrorBody,
245 },
246
247 #[error("llm error: {1}")]
249 LlmError(StatusCode, String),
250
251 #[error("visualize error: {1}")]
254 VisualizeError(StatusCode, String),
255
256 #[error("internal server error: {0}")]
258 Internal(#[from] anyhow::Error),
259}
260
261#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
273#[serde(untagged)]
274pub enum RecallErrorBody {
275 WithHint { error: String, hint: String },
277 JustError { error: String },
279}
280
281impl IntoResponse for ApiError {
282 fn into_response(self) -> Response {
283 let (status, body) = match self {
284 ApiError::BadRequest(msg) => (StatusCode::BAD_REQUEST, json!({"detail": msg})),
285 ApiError::Unauthorized => (StatusCode::UNAUTHORIZED, json!({"detail": "Unauthorized"})),
286 ApiError::Forbidden(msg) => (StatusCode::FORBIDDEN, json!({"detail": msg})),
287 ApiError::NotFound(msg) => (StatusCode::NOT_FOUND, json!({"detail": msg})),
288 ApiError::Conflict(msg) => (StatusCode::CONFLICT, json!({"detail": msg})),
289 ApiError::Validation(details) => {
290 let mut map = serde_json::Map::new();
291 map.insert("detail".into(), details.detail);
292 if let Some(body) = details.body {
293 map.insert("body".into(), body);
294 }
295 (StatusCode::BAD_REQUEST, Value::Object(map))
296 }
297 ApiError::LoginBadCredentials => (
298 StatusCode::BAD_REQUEST,
299 json!({"detail": "LOGIN_BAD_CREDENTIALS"}),
300 ),
301 ApiError::LoginUserNotVerified => (
302 StatusCode::BAD_REQUEST,
303 json!({"detail": "LOGIN_USER_NOT_VERIFIED"}),
304 ),
305 ApiError::RegisterUserAlreadyExists => (
306 StatusCode::BAD_REQUEST,
307 json!({"detail": "REGISTER_USER_ALREADY_EXISTS"}),
308 ),
309 ApiError::RegisterInvalidPassword(reason) => (
310 StatusCode::BAD_REQUEST,
311 json!({"detail": {"code": "REGISTER_INVALID_PASSWORD", "reason": reason}}),
312 ),
313 ApiError::ResetPasswordBadToken => (
314 StatusCode::BAD_REQUEST,
315 json!({"detail": "RESET_PASSWORD_BAD_TOKEN"}),
316 ),
317 ApiError::ResetPasswordInvalidPassword(reason) => (
318 StatusCode::BAD_REQUEST,
319 json!({"detail": {"code": "RESET_PASSWORD_INVALID_PASSWORD", "reason": reason}}),
320 ),
321 ApiError::VerifyUserBadToken => (
322 StatusCode::BAD_REQUEST,
323 json!({"detail": "VERIFY_USER_BAD_TOKEN"}),
324 ),
325 ApiError::VerifyUserAlreadyVerified => (
326 StatusCode::BAD_REQUEST,
327 json!({"detail": "VERIFY_USER_ALREADY_VERIFIED"}),
328 ),
329 ApiError::UpdateUserEmailAlreadyExists => (
330 StatusCode::BAD_REQUEST,
331 json!({"detail": "UPDATE_USER_EMAIL_ALREADY_EXISTS"}),
332 ),
333 ApiError::UpdateUserInvalidPassword(reason) => (
334 StatusCode::BAD_REQUEST,
335 json!({"detail": {"code": "UPDATE_USER_INVALID_PASSWORD", "reason": reason}}),
336 ),
337 ApiError::ApiKeyEnvelope(message) => (
338 StatusCode::BAD_REQUEST,
339 json!({"error": {"message": message}}),
340 ),
341 ApiError::PipelineErrored {
342 pipeline_source,
343 run_info,
344 } => match pipeline_source {
345 PipelineErrorSource::Improve => {
346 #[allow(clippy::expect_used, reason = "invariant is upheld by construction")]
352 let status =
353 StatusCode::from_u16(420).expect("420 is a valid HTTP status code");
354 return (status, Json(run_info)).into_response();
355 }
356 _ => (StatusCode::INTERNAL_SERVER_ERROR, run_info),
357 },
358 ApiError::Teapot(msg) => (StatusCode::IM_A_TEAPOT, json!({"detail": msg})),
359 ApiError::WriteEndpointError {
360 error,
361 detail,
362 status,
363 } => (status, json!({"error": error, "detail": detail})),
364 ApiError::WriteEnvelopeError(msg, status) => (status, json!({"error": msg})),
365 ApiError::ErrorMessageError(msg, status) => (status, json!({"message": msg})),
366 ApiError::OntologyEnvelope(msg, status) => (status, json!({"error": msg})),
367 ApiError::DeprecatedConflict(msg) => (StatusCode::CONFLICT, json!({"error": msg})),
368 ApiError::NotImplemented(msg) => (StatusCode::NOT_IMPLEMENTED, json!({"detail": msg})),
369 ApiError::ServiceUnavailable(msg) => {
370 (StatusCode::SERVICE_UNAVAILABLE, json!({"error": msg}))
371 }
372 ApiError::NotImplementedStub { code, detail } => {
373 let raw = format!(
378 "{{\"detail\":{},\"code\":{}}}",
379 serde_json::Value::String(detail.to_string()),
380 serde_json::Value::String(code.to_string()),
381 );
382 #[allow(clippy::expect_used, reason = "invariant is upheld by construction")]
383 return (
384 StatusCode::NOT_IMPLEMENTED,
385 axum::response::Response::builder()
386 .status(StatusCode::NOT_IMPLEMENTED)
387 .header(axum::http::header::CONTENT_TYPE, "application/json")
388 .body(axum::body::Body::from(raw))
389 .expect("valid response builder args"),
390 )
391 .into_response();
392 }
393 ApiError::SearchError {
394 status,
395 error,
396 detail,
397 } => (status, json!({"error": error, "detail": detail})),
398 ApiError::RecallError { status, body } => {
399 let value = serde_json::to_value(&body).unwrap_or_else(|_| json!({}));
400 (status, value)
401 }
402 ApiError::LlmError(status, msg) => (status, json!({"error": msg})),
403 ApiError::VisualizeError(status, msg) => (status, json!({"error": msg})),
404 ApiError::Internal(err) => (
405 StatusCode::INTERNAL_SERVER_ERROR,
406 json!({"detail": err.to_string()}),
407 ),
408 };
409 (status, Json(body)).into_response()
410 }
411}
412
413#[derive(Debug, Error)]
417pub enum ServerError {
418 #[error("I/O error: {0}")]
420 Io(#[from] std::io::Error),
421
422 #[error("lifecycle error: {0}")]
424 Lifecycle(#[from] crate::lifecycle::LifecycleError),
425
426 #[error("server error: {0}")]
428 Other(#[from] anyhow::Error),
429}
430
431#[cfg(test)]
434#[allow(
435 clippy::unwrap_used,
436 clippy::expect_used,
437 reason = "test code — panics are acceptable failures"
438)]
439mod tests {
440 use super::*;
441 use axum::body::to_bytes;
442 use serde_json::Value;
443
444 async fn body_json(resp: Response) -> Value {
445 let bytes = to_bytes(resp.into_body(), usize::MAX).await.expect("body");
446 serde_json::from_slice(&bytes).expect("json")
447 }
448
449 #[tokio::test]
450 async fn test_bad_request() {
451 let resp = ApiError::BadRequest("oops".into()).into_response();
452 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
453 let body = body_json(resp).await;
454 assert_eq!(body["detail"], "oops");
455 }
456
457 #[tokio::test]
458 async fn test_unauthorized() {
459 let resp = ApiError::Unauthorized.into_response();
460 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
461 let body = body_json(resp).await;
462 assert_eq!(body["detail"], "Unauthorized");
463 }
464
465 #[tokio::test]
466 async fn test_validation() {
467 let details = ValidationDetails {
468 detail: serde_json::json!([{"loc": ["field"], "msg": "required"}]),
469 body: Some(serde_json::json!({"x": 1})),
470 };
471 let resp = ApiError::Validation(details).into_response();
472 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
473 let body = body_json(resp).await;
474 assert!(body["detail"].is_array());
475 assert!(body["body"].is_object());
476 }
477
478 #[tokio::test]
479 async fn test_login_bad_credentials() {
480 let resp = ApiError::LoginBadCredentials.into_response();
481 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
482 let body = body_json(resp).await;
483 assert_eq!(body["detail"], "LOGIN_BAD_CREDENTIALS");
484 }
485
486 #[tokio::test]
487 async fn test_teapot_with_message() {
488 let resp = ApiError::Teapot("Error retrieving datasets: db error".into()).into_response();
489 assert_eq!(resp.status(), StatusCode::IM_A_TEAPOT);
490 let body = body_json(resp).await;
491 assert!(
492 body["detail"]
493 .as_str()
494 .unwrap_or("")
495 .contains("retrieving datasets")
496 );
497 }
498
499 #[tokio::test]
500 async fn test_write_endpoint_error() {
501 let resp = ApiError::WriteEndpointError {
502 error: "Pipeline run errored".into(),
503 detail: Some("inner".into()),
504 status: StatusCode::INTERNAL_SERVER_ERROR,
505 }
506 .into_response();
507 assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
508 let body = body_json(resp).await;
509 assert_eq!(body["error"], "Pipeline run errored");
510 }
511
512 #[tokio::test]
513 async fn test_write_envelope_error() {
514 let resp = ApiError::WriteEnvelopeError("Dataset not found".into(), StatusCode::NOT_FOUND)
515 .into_response();
516 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
517 let body = body_json(resp).await;
518 assert_eq!(body["error"], "Dataset not found");
519 }
520
521 #[tokio::test]
522 async fn test_error_message_error() {
523 let resp =
524 ApiError::ErrorMessageError("Dataset (abc) not found.".into(), StatusCode::NOT_FOUND)
525 .into_response();
526 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
527 let body = body_json(resp).await;
528 assert_eq!(body["message"], "Dataset (abc) not found.");
529 }
530
531 #[tokio::test]
532 async fn test_deprecated_conflict() {
533 let resp = ApiError::DeprecatedConflict("some error".into()).into_response();
534 assert_eq!(resp.status(), StatusCode::CONFLICT);
535 let body = body_json(resp).await;
536 assert_eq!(body["error"], "some error");
537 }
538
539 #[tokio::test]
540 async fn test_not_implemented() {
541 let resp =
542 ApiError::NotImplemented("Storage scheme 's3' not supported".into()).into_response();
543 assert_eq!(resp.status(), StatusCode::NOT_IMPLEMENTED);
544 let body = body_json(resp).await;
545 assert_eq!(body["detail"], "Storage scheme 's3' not supported");
546 }
547
548 #[tokio::test]
549 async fn test_not_implemented_stub_status_and_field_order() {
550 let resp = ApiError::NotImplementedStub {
551 code: "X",
552 detail: "y",
553 }
554 .into_response();
555 assert_eq!(resp.status(), StatusCode::NOT_IMPLEMENTED);
556
557 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
559 .await
560 .expect("body");
561 let body_str = std::str::from_utf8(&bytes).expect("utf8");
562 assert_eq!(body_str, r#"{"detail":"y","code":"X"}"#);
563 }
564
565 #[tokio::test]
566 async fn test_not_implemented_stub_notebook_run() {
567 let resp = ApiError::NotImplementedStub {
568 code: "NOTEBOOK_RUN_NOT_IMPLEMENTED",
569 detail: "Notebook cell execution is not implemented in this build",
570 }
571 .into_response();
572 assert_eq!(resp.status(), StatusCode::NOT_IMPLEMENTED);
573 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
574 .await
575 .expect("body");
576 let body_str = std::str::from_utf8(&bytes).expect("utf8");
577 assert_eq!(
578 body_str,
579 r#"{"detail":"Notebook cell execution is not implemented in this build","code":"NOTEBOOK_RUN_NOT_IMPLEMENTED"}"#
580 );
581 }
582
583 #[tokio::test]
584 async fn test_not_implemented_stub_responses() {
585 let resp = ApiError::NotImplementedStub {
586 code: "RESPONSES_NOT_IMPLEMENTED",
587 detail: "OpenAI Responses API surface is not implemented in this build",
588 }
589 .into_response();
590 assert_eq!(resp.status(), StatusCode::NOT_IMPLEMENTED);
591 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
592 .await
593 .expect("body");
594 let body_str = std::str::from_utf8(&bytes).expect("utf8");
595 assert_eq!(
596 body_str,
597 r#"{"detail":"OpenAI Responses API surface is not implemented in this build","code":"RESPONSES_NOT_IMPLEMENTED"}"#
598 );
599 }
600
601 #[tokio::test]
604 async fn test_pipeline_errored_cognify_returns_500() {
605 let resp = ApiError::PipelineErrored {
606 pipeline_source: PipelineErrorSource::Cognify,
607 run_info: serde_json::json!({"error": "Pipeline run errored", "detail": "boom"}),
608 }
609 .into_response();
610 assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
611 let body = body_json(resp).await;
612 assert_eq!(body["error"], "Pipeline run errored");
613 assert_eq!(body["detail"], "boom");
614 }
615
616 #[tokio::test]
617 async fn test_pipeline_errored_memify_returns_500() {
618 let resp = ApiError::PipelineErrored {
619 pipeline_source: PipelineErrorSource::Memify,
620 run_info: serde_json::json!({"error": "Pipeline run errored", "detail": "memify fail"}),
621 }
622 .into_response();
623 assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
624 }
625
626 #[tokio::test]
627 async fn test_pipeline_errored_improve_returns_420() {
628 let run_info = serde_json::json!({
629 "status": "PipelineRunErrored",
630 "pipeline_run_id": "00000000-0000-0000-0000-000000000001",
631 "dataset_id": "00000000-0000-0000-0000-000000000002",
632 "dataset_name": "test",
633 "error": "improve failed"
634 });
635 let resp = ApiError::PipelineErrored {
636 pipeline_source: PipelineErrorSource::Improve,
637 run_info: run_info.clone(),
638 }
639 .into_response();
640 assert_eq!(resp.status().as_u16(), 420);
642 let body = body_json(resp).await;
644 assert_eq!(body["status"], "PipelineRunErrored");
645 assert_eq!(body["error"], "improve failed");
646 assert_ne!(body["error"], "Pipeline run errored");
648 }
649
650 #[tokio::test]
653 async fn test_search_error_envelope() {
654 let resp = ApiError::SearchError {
655 status: StatusCode::FORBIDDEN,
656 error: "Permission denied".into(),
657 detail: Some("No read on dataset".into()),
658 }
659 .into_response();
660 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
661 let body = body_json(resp).await;
662 assert_eq!(body["error"], "Permission denied");
663 assert_eq!(body["detail"], "No read on dataset");
664 }
665
666 #[tokio::test]
667 async fn test_search_error_with_null_detail() {
668 let resp = ApiError::SearchError {
669 status: StatusCode::INTERNAL_SERVER_ERROR,
670 error: "Internal server error".into(),
671 detail: None,
672 }
673 .into_response();
674 assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
675 let body = body_json(resp).await;
676 assert_eq!(body["error"], "Internal server error");
677 assert!(body["detail"].is_null());
678 }
679
680 #[tokio::test]
681 async fn test_recall_error_with_hint_envelope() {
682 let resp = ApiError::RecallError {
683 status: StatusCode::UNPROCESSABLE_ENTITY,
684 body: RecallErrorBody::WithHint {
685 error: "Recall prerequisites not met".into(),
686 hint: "Run cognify first".into(),
687 },
688 }
689 .into_response();
690 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
691 let body = body_json(resp).await;
692 assert_eq!(body["error"], "Recall prerequisites not met");
693 assert_eq!(body["hint"], "Run cognify first");
694 assert!(body.get("detail").is_none());
696 }
697
698 #[tokio::test]
699 async fn test_recall_error_just_error_envelope() {
700 let resp = ApiError::RecallError {
701 status: StatusCode::CONFLICT,
702 body: RecallErrorBody::JustError {
703 error: "An error occurred during recall.".into(),
704 },
705 }
706 .into_response();
707 assert_eq!(resp.status(), StatusCode::CONFLICT);
708 let body = body_json(resp).await;
709 assert_eq!(body["error"], "An error occurred during recall.");
710 assert!(body.get("detail").is_none());
712 assert!(body.get("hint").is_none());
713 }
714
715 #[tokio::test]
716 async fn test_llm_error_envelope() {
717 let resp =
718 ApiError::LlmError(StatusCode::CONFLICT, "Network failure".into()).into_response();
719 assert_eq!(resp.status(), StatusCode::CONFLICT);
720 let body = body_json(resp).await;
721 assert_eq!(body["error"], "Network failure");
722 assert!(body.get("detail").is_none());
723 }
724
725 #[tokio::test]
726 async fn test_visualize_error_envelope() {
727 let resp = ApiError::VisualizeError(
728 StatusCode::FORBIDDEN,
729 "Superuser privileges required for multi-user visualization".into(),
730 )
731 .into_response();
732 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
733 let body = body_json(resp).await;
734 assert_eq!(
735 body["error"],
736 "Superuser privileges required for multi-user visualization"
737 );
738 assert!(body.get("detail").is_none());
739 }
740
741 #[tokio::test]
742 async fn test_pipeline_errored_sync_returns_500() {
743 let resp = ApiError::PipelineErrored {
744 pipeline_source: PipelineErrorSource::Sync,
745 run_info: serde_json::json!({"error": "Pipeline run errored", "detail": "sync fail"}),
746 }
747 .into_response();
748 assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
749 }
750}