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>;