Skip to main content

cognee_http_server/
error.rs

1//! Error types for the cognee HTTP server.
2//!
3//! `ApiError` mirrors Python's `CogneeApiError` / `RequestValidationError` shapes
4//! (see `cognee/api/client.py`). `ServerError` is used for startup/runtime failures.
5
6use axum::{
7    Json,
8    http::StatusCode,
9    response::{IntoResponse, Response},
10};
11use serde::Serialize;
12use serde_json::{Value, json};
13use thiserror::Error;
14
15// ─── PipelineErrorSource ──────────────────────────────────────────────────────
16
17/// Which pipeline router produced a `PipelineErrored` event.
18///
19/// The `IntoResponse` implementation of `ApiError::PipelineErrored` uses this
20/// to choose the HTTP status code and body shape per Python parity:
21///
22/// | Source | Status | Body |
23/// |---|---|---|
24/// | `Improve` | **420** | raw serialised `PipelineRunInfoDTO` |
25/// | all others | 500 | `{"error": "Pipeline run errored", "detail": "<msg>"}` |
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum PipelineErrorSource {
28    /// `/cognify` — 500, canonical envelope.
29    Cognify,
30    /// `/memify` — 500, canonical envelope.
31    Memify,
32    /// `/improve` — **420**, raw run-info body (Python parity quirk).
33    Improve,
34    /// `/remember` — does NOT use this variant; its catch-all is 409.
35    /// Included for completeness and future use.
36    Remember,
37    /// `/sync` — 500, canonical envelope (not yet wired).
38    Sync,
39}
40
41// ─── Validation details ──────────────────────────────────────────────────────
42
43/// Carries the Python-shaped validation error payload:
44/// `{"detail": [...], "body": <original_body>}`.
45#[derive(Debug, Clone, Serialize)]
46pub struct ValidationDetails {
47    /// List of validation errors (each entry mirrors a Pydantic error item).
48    pub detail: Value,
49    /// The original request body that caused the error, if available.
50    pub body: Option<Value>,
51}
52
53// ─── ApiError ────────────────────────────────────────────────────────────────
54
55/// HTTP-layer error type.  Every handler returns `Result<T, ApiError>` and the
56/// `IntoResponse` impl maps variants to the Python-compatible JSON envelopes.
57#[derive(Debug, Error)]
58pub enum ApiError {
59    /// 400 `{"detail": "..."}` — generic bad-request condition.
60    #[error("bad request: {0}")]
61    BadRequest(String),
62
63    /// 401 `{"detail": "Unauthorized"}`.
64    #[error("unauthorized")]
65    Unauthorized,
66
67    /// 403 `{"detail": "..."}`.
68    #[error("forbidden: {0}")]
69    Forbidden(String),
70
71    /// 404 `{"detail": "..."}`.
72    #[error("not found: {0}")]
73    NotFound(String),
74
75    /// 409 `{"detail": "..."}`.
76    #[error("conflict: {0}")]
77    Conflict(String),
78
79    /// 400 `{"detail": [...], "body": ...}` — serde/Pydantic-style validation.
80    #[error("validation error")]
81    Validation(ValidationDetails),
82
83    /// 400 `{"detail": "LOGIN_BAD_CREDENTIALS"}` — fastapi-users compat.
84    #[error("login bad credentials")]
85    LoginBadCredentials,
86
87    /// 400 `{"detail": "LOGIN_USER_NOT_VERIFIED"}` — fastapi-users compat.
88    #[error("login user not verified")]
89    LoginUserNotVerified,
90
91    /// 400 `{"detail": "REGISTER_USER_ALREADY_EXISTS"}` — fastapi-users compat.
92    #[error("register user already exists")]
93    RegisterUserAlreadyExists,
94
95    /// 400 `{"detail": {"code": "REGISTER_INVALID_PASSWORD", "reason": "..."}}`.
96    #[error("register invalid password: {0}")]
97    RegisterInvalidPassword(String),
98
99    /// 400 `{"detail": "RESET_PASSWORD_BAD_TOKEN"}` — fastapi-users compat.
100    #[error("reset password bad token")]
101    ResetPasswordBadToken,
102
103    /// 400 `{"detail": {"code": "RESET_PASSWORD_INVALID_PASSWORD", "reason": "..."}}`.
104    #[error("reset password invalid password: {0}")]
105    ResetPasswordInvalidPassword(String),
106
107    /// 400 `{"detail": "VERIFY_USER_BAD_TOKEN"}` — fastapi-users compat.
108    #[error("verify user bad token")]
109    VerifyUserBadToken,
110
111    /// 400 `{"detail": "VERIFY_USER_ALREADY_VERIFIED"}` — fastapi-users compat.
112    #[error("verify user already verified")]
113    VerifyUserAlreadyVerified,
114
115    /// 400 `{"detail": "UPDATE_USER_EMAIL_ALREADY_EXISTS"}` — fastapi-users compat.
116    #[error("update user email already exists")]
117    UpdateUserEmailAlreadyExists,
118
119    /// 400 `{"detail": {"code": "UPDATE_USER_INVALID_PASSWORD", "reason": "..."}}`.
120    #[error("update user invalid password: {0}")]
121    UpdateUserInvalidPassword(String),
122
123    /// 400 `{"error": {"message": "..."}}` — unique envelope used ONLY by the api-keys router.
124    /// **Never** use this variant outside `routers/api_keys.rs`.
125    #[error("api key error: {0}")]
126    ApiKeyEnvelope(String),
127
128    /// 420 (Improve) or 500 (all others) — pipeline job returned an error.
129    ///
130    /// * `pipeline_source = Improve` → HTTP 420, body is the raw `run_info` value
131    ///   (Python's `/improve` returns the `PipelineRunInfo` object directly,
132    ///   not the canonical envelope).
133    /// * all other sources → HTTP 500, body is
134    ///   `{"error": "Pipeline run errored", "detail": "<msg>"}`.
135    ///
136    /// `/remember` does **not** use this variant — its catch-all is
137    /// `ApiError::DeprecatedConflict("An error occurred during remember.")` per
138    /// Python parity (produces `{"error": "..."}`, not `{"detail": "..."}`).
139    ///
140    /// `/improve`'s generic catch-all similarly uses `ApiError::DeprecatedConflict`.
141    ///
142    /// Note: the field is named `pipeline_source` (not `source`) to prevent
143    /// `thiserror` from treating it as an error-chain source.
144    #[error("pipeline errored ({pipeline_source:?})")]
145    PipelineErrored {
146        pipeline_source: PipelineErrorSource,
147        /// For `Improve`: the full serialised `PipelineRunInfoDTO`.
148        /// For others: `serde_json::json!({"error": "Pipeline run errored", "detail": "<msg>"})`.
149        run_info: serde_json::Value,
150    },
151
152    /// 418 `{"detail": "<msg>"}` — Python fallback for uncategorized errors.
153    ///
154    /// Changed from a unit variant to carry a message string for Python parity
155    /// (`datasets` and `add` routers use this with dynamic messages).
156    #[error("teapot: {0}")]
157    Teapot(String),
158
159    // ── P2 error variants ─────────────────────────────────────────────────────
160    /// `add`/`update` error envelope: `{"error": "...", "detail": "..."}`.
161    ///
162    /// Used only by `routers/add.rs` and `routers/update.rs` — the only routes
163    /// that deviate from the canonical `{"detail": "..."}` shape.
164    #[error("write endpoint error: {error}")]
165    WriteEndpointError {
166        error: String,
167        detail: Option<String>,
168        status: StatusCode,
169    },
170
171    /// `<status> {"error": "..."}` — used by datasets 2.2 catch-all (409),
172    /// datasets 2.6 (404), and datasets 2.8 (404).
173    #[error("write envelope error: {0}")]
174    WriteEnvelopeError(String, StatusCode),
175
176    /// `<status> {"message": "..."}` — used by datasets 2.3 (404).
177    #[error("error message: {0}")]
178    ErrorMessageError(String, StatusCode),
179
180    /// `<status> {"error": "..."}` — used by ontologies and forget.
181    #[error("error envelope: {0}")]
182    OntologyEnvelope(String, StatusCode),
183
184    /// `409 {"error": "..."}` — used by the deprecated `/delete` endpoint and by
185    /// the `/remember` and `/improve` catch-all error paths.
186    ///
187    /// Python parity: those routers return `JSONResponse({"error": "..."})` with
188    /// status 409, **not** `HTTPException(409, detail=...)`, so the body key is
189    /// `"error"` rather than `"detail"`.  This is intentionally different from
190    /// `ApiError::Conflict` which uses the `"detail"` key.
191    #[error("conflict error: {0}")]
192    DeprecatedConflict(String),
193
194    /// 501 `{"detail": "..."}` — not implemented (e.g. unsupported storage scheme).
195    #[error("not implemented: {0}")]
196    NotImplemented(String),
197
198    /// 503 `{"error": "..."}` — service unavailable.
199    ///
200    /// Used by `/api/v1/remember/entry` when the session cache is not
201    /// configured. Mirrors Python's
202    /// `JSONResponse(status_code=503, content={"error": str(error)})`
203    /// (`get_remember_router.py:158-160`).
204    #[error("service unavailable: {0}")]
205    ServiceUnavailable(String),
206
207    /// 501 `{"detail": "...", "code": "..."}` — structured stub envelope.
208    ///
209    /// Field order is wire-load-bearing: `detail` first, then `code`, matching
210    /// Python's `JSONResponse({"detail": ..., "code": ...})` insertion order.
211    /// Used by the notebooks `/run` stub and the responses stub.
212    #[error("not implemented stub: {detail}")]
213    NotImplementedStub {
214        code: &'static str,
215        detail: &'static str,
216    },
217
218    // ── P4 read-path error variants ───────────────────────────────────────────
219    //
220    // DO NOT NORMALIZE — these envelopes are wire-compatibility constraints
221    // pinned to Python. See `routers/README.md §3.1` and the per-router specs
222    // (`routers/search.md`, `routers/recall.md`, `routers/llm.md`,
223    // `routers/visualize.md`) before touching them.
224    /// `<status> {"error": "<error>", "detail": "<detail>"}` — used by
225    /// `/api/v1/search` (403/422/500) and `GET /api/v1/search` (500).
226    /// Mirrors Python's `ErrorResponse` model.
227    #[error("search error: {error}")]
228    SearchError {
229        status: StatusCode,
230        error: String,
231        detail: Option<String>,
232    },
233
234    /// Three-shaped envelope used only by `/api/v1/recall`.
235    ///
236    /// - `WithHint { error, hint }` for 422 prerequisite errors.
237    /// - `JustError { error }` for the 409 catch-all and the GET-history 500.
238    ///
239    /// Permission denied is NOT encoded here — the recall handler returns
240    /// `200 []` directly without going through `ApiError`.
241    #[error("recall error")]
242    RecallError {
243        status: StatusCode,
244        body: RecallErrorBody,
245    },
246
247    /// `<status> {"error": "<msg>"}` — used by `/api/v1/llm/*` for 400/409/422.
248    #[error("llm error: {1}")]
249    LlmError(StatusCode, String),
250
251    /// `<status> {"error": "<msg>"}` — used by `/api/v1/visualize/*` for the
252    /// 409 catch-all and the 403 superuser-only failure.
253    #[error("visualize error: {1}")]
254    VisualizeError(StatusCode, String),
255
256    /// 500 — unhandled internal error.
257    #[error("internal server error: {0}")]
258    Internal(#[from] anyhow::Error),
259}
260
261// ─── RecallErrorBody ──────────────────────────────────────────────────────────
262
263/// Recall-router-specific error envelopes.
264///
265/// Python produces three distinct shapes for `/api/v1/recall`:
266/// - `{error, hint}` for 422 prerequisite errors.
267/// - `{error}` for the 409 catch-all and the GET-history 500.
268/// - silent `200 []` for permission denied (NOT encoded here — handled at the
269///   handler layer by returning `Ok(Json(vec![]))` without an `ApiError`).
270///
271/// Serialized as an `untagged` enum so the JSON shape per variant matches Python.
272#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
273#[serde(untagged)]
274pub enum RecallErrorBody {
275    /// `{"error": "...", "hint": "..."}` — 422 prerequisite errors.
276    WithHint { error: String, hint: String },
277    /// `{"error": "..."}` — 409 catch-all and GET-history 500.
278    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                    // Python parity: /improve returns the raw PipelineRunInfo
347                    // object as the body with HTTP 420, not the canonical
348                    // {"error":..., "detail":...} envelope.
349                    // StatusCode 420 is not a standard IANA code; construct
350                    // it from the raw integer.
351                    #[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                // Field order is load-bearing: detail first, then code
374                // (matches Python's JSONResponse dict insertion order).
375                // serde_json::Map uses BTreeMap (no preserve_order feature),
376                // so we emit a raw JSON string to guarantee field order.
377                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// ─── ServerError ─────────────────────────────────────────────────────────────
414
415/// Errors that can occur at server startup or runtime (not per-request).
416#[derive(Debug, Error)]
417pub enum ServerError {
418    /// An I/O error (e.g. bind failure).
419    #[error("I/O error: {0}")]
420    Io(#[from] std::io::Error),
421
422    /// Lifecycle startup failed.
423    #[error("lifecycle error: {0}")]
424    Lifecycle(#[from] crate::lifecycle::LifecycleError),
425
426    /// Catch-all for other startup failures.
427    #[error("server error: {0}")]
428    Other(#[from] anyhow::Error),
429}
430
431// ─── Unit tests ──────────────────────────────────────────────────────────────
432
433#[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        // Verify exact byte output — field order is load-bearing.
558        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    // ── PipelineErrored variant tests ─────────────────────────────────────────
602
603    #[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        // 420 is the Python-parity quirk for /improve
641        assert_eq!(resp.status().as_u16(), 420);
642        // Body is the raw PipelineRunInfoDTO, NOT the canonical envelope
643        let body = body_json(resp).await;
644        assert_eq!(body["status"], "PipelineRunErrored");
645        assert_eq!(body["error"], "improve failed");
646        // Must NOT have the canonical {"error": "Pipeline run errored"} wrapper
647        assert_ne!(body["error"], "Pipeline run errored");
648    }
649
650    // ── P4 envelope tests ─────────────────────────────────────────────────────
651
652    #[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        // No `detail` key — recall uses `hint`, not `detail`.
695        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        // Single-field envelope — no `detail`, no `hint`.
711        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}