Skip to main content

ag_ui/axum/
error.rs

1//! What can go wrong before the stream starts, and what the client sees.
2//!
3//! Everything here happens *before* `RUN_STARTED`. Once the SSE body is open
4//! the status line is already sent, so failures from then on are `RUN_ERROR`
5//! events inside a `200` stream — that is [`crate::server`](https://kimsoungryoul.github.io/ag-ui-rust/api/ag_ui/server/index.html)'s job, not this
6//! module's. What is left is the handful of ways a request can be refused:
7//! a body that is not AG-UI JSON, and an `Accept` header this build cannot
8//! satisfy.
9//!
10//! A refusal answers with a JSON object rather than a bare status line, because
11//! the caller is a program:
12//!
13//! ```json
14//! {"code": "INVALID_INPUT", "message": "missing field `messages` at line 1 column 34"}
15//! ```
16
17use crate::encode::supported_media_types;
18use axum::Json;
19use axum::http::{StatusCode, header};
20use axum::response::{IntoResponse, Response};
21use serde_json::json;
22use thiserror::Error;
23
24/// A request this endpoint refused, before any events were produced.
25///
26/// Implements [`IntoResponse`], so it doubles as the rejection type of
27/// [`AgUiInput`](crate::axum::AgUiInput) — a handler can take
28/// `Result<AgUiInput, Error>` and inspect the failure, or take `AgUiInput` and
29/// let axum answer with the response below.
30#[derive(Debug, Error)]
31#[non_exhaustive]
32pub enum Error {
33    /// The request body could not be read at all — a connection that died
34    /// mid-body, or a payload over the configured
35    /// [`DefaultBodyLimit`](axum::extract::DefaultBodyLimit).
36    ///
37    /// Carries the status axum's own extractor chose, so a body limit stays a
38    /// `413` rather than being flattened into a `400`.
39    #[error("{message}")]
40    Body {
41        /// The status to answer with.
42        status: StatusCode,
43        /// What axum said about it.
44        message: String,
45    },
46
47    /// The `Content-Type` was set to something that is not JSON.
48    ///
49    /// An absent header is accepted — `curl -d` and hand-written clients often
50    /// omit it. A *wrong* one is refused rather than sniffed, which also means
51    /// a cross-origin HTML form (whose content type is always one of three
52    /// non-JSON values it cannot override) can never reach an agent.
53    #[error("expected a JSON request body, got Content-Type {found:?}")]
54    ContentType {
55        /// The offending header value.
56        found: String,
57    },
58
59    /// The body was JSON, but not a
60    /// [`RunAgentInput`](https://kimsoungryoul.github.io/ag-ui-rust/api/ag_ui/input/struct.RunAgentInput.html).
61    ///
62    /// The message is serde's, so it names the field and the offset.
63    #[error("the request body is not a valid AG-UI RunAgentInput: {0}")]
64    Decode(#[from] serde_json::Error),
65
66    /// The request body was empty.
67    #[error("the request body is empty; expected an AG-UI RunAgentInput object")]
68    EmptyBody,
69
70    /// Content negotiation found nothing this build can emit.
71    ///
72    /// See [`negotiate`](crate::axum::respond::negotiate).
73    #[error(
74        "cannot produce any media type this request accepts ({accept:?}); \
75         this endpoint emits: {}",
76        supported_media_types().join(", ")
77    )]
78    NotAcceptable {
79        /// The `Accept` header that could not be satisfied.
80        accept: String,
81    },
82}
83
84impl Error {
85    /// The status this error answers with.
86    pub const fn status(&self) -> StatusCode {
87        match self {
88            Self::Body { status, .. } => *status,
89            Self::ContentType { .. } => StatusCode::UNSUPPORTED_MEDIA_TYPE,
90            Self::Decode(_) | Self::EmptyBody => StatusCode::BAD_REQUEST,
91            Self::NotAcceptable { .. } => StatusCode::NOT_ACCEPTABLE,
92        }
93    }
94
95    /// The machine-readable code placed in the response body.
96    ///
97    /// Deliberately not derived from the status: a client that wants to branch
98    /// should not have to distinguish two `400`s by parsing prose.
99    pub const fn code(&self) -> &'static str {
100        match self {
101            Self::Body { .. } => "INVALID_BODY",
102            Self::ContentType { .. } => "UNSUPPORTED_MEDIA_TYPE",
103            Self::Decode(_) | Self::EmptyBody => "INVALID_INPUT",
104            Self::NotAcceptable { .. } => "NOT_ACCEPTABLE",
105        }
106    }
107}
108
109impl IntoResponse for Error {
110    fn into_response(self) -> Response {
111        let body = Json(json!({ "code": self.code(), "message": self.to_string() }));
112        let mut response = (self.status(), body).into_response();
113        // The answer depends on `Accept`, including when the answer is a 406.
114        // Without this a shared cache can serve one client's refusal to another
115        // client that would have been served happily.
116        response
117            .headers_mut()
118            .insert(header::VARY, header::HeaderValue::from_static("accept"));
119        response
120    }
121}
122
123/// Result alias for this crate.
124pub type Result<T, E = Error> = core::result::Result<T, E>;