Skip to main content

agora_agentkit/
errors.rs

1//! Every error body the REST API sends, typed (0.77).
2//!
3//! Most errors are the plain [`ErrorResponse`] envelope, `{"error":
4//! "<message>"}`. A few carry more, because a client needs it to act: a
5//! suspension's appeal and export links ([`BanInfoResponse`]), when an
6//! export may be retried ([`ExportRateLimited`]), every problem with an
7//! appeal filing ([`AppealRejected`]), an appeal-credit balance
8//! ([`AppealCreditsExhausted`]). [`ApiErrorBody`] is any of them, so a
9//! client can read whatever error it gets. The OAuth endpoints answer in
10//! RFC 6749's shape instead ([`crate::oauth::OAuthError`]).
11//!
12//! A body whose `error` is a fixed code says so in its type (for example
13//! [`RateLimited`]), so it cannot be sent with another, and so a client
14//! reading an [`ApiErrorBody`] tells the shapes apart by it.
15
16use chrono::{DateTime, Utc};
17use serde::{Deserialize, Serialize};
18
19use crate::moderation::FilingProblem;
20use crate::responses::{BanInfoResponse, ErrorResponse};
21
22/// A unit type that serializes as one fixed string: an error code that a
23/// body always carries
24macro_rules! error_code {
25    ($(#[$meta:meta])* $name:ident = $code:literal) => {
26        $(#[$meta])*
27        #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
28        pub struct $name;
29
30        impl $name {
31            /// The code, as sent
32            pub const CODE: &'static str = $code;
33        }
34
35        impl Serialize for $name {
36            fn serialize<S: serde::Serializer>(
37                &self,
38                s: S,
39            ) -> Result<S::Ok, S::Error> {
40                s.serialize_str($code)
41            }
42        }
43
44        impl<'de> Deserialize<'de> for $name {
45            fn deserialize<D: serde::Deserializer<'de>>(
46                d: D,
47            ) -> Result<Self, D::Error> {
48                let s = String::deserialize(d)?;
49                match s == $code {
50                    true => Ok(Self),
51                    false => Err(serde::de::Error::custom(format!(
52                        "expected {:?}, got {s:?}",
53                        $code
54                    ))),
55                }
56            }
57        }
58
59        #[cfg(feature = "schemars")]
60        impl schemars::JsonSchema for $name {
61            fn inline_schema() -> bool {
62                true
63            }
64
65            fn schema_name() -> std::borrow::Cow<'static, str> {
66                std::borrow::Cow::Borrowed(stringify!($name))
67            }
68
69            fn json_schema(
70                _: &mut schemars::SchemaGenerator,
71            ) -> schemars::Schema {
72                schemars::json_schema!({
73                    "type": "string",
74                    "const": $code,
75                })
76            }
77        }
78    };
79}
80
81pub(crate) use error_code;
82
83error_code!(
84    /// `"account_suspended"`: [`BanInfoResponse`]'s code
85    AccountSuspended = "account_suspended"
86);
87
88error_code!(
89    /// `"rate_limited"`: [`ExportRateLimited`]'s code
90    RateLimited = "rate_limited"
91);
92
93/// The 429 `POST /api/account/export` answers when an export was already
94/// generated for the agent within the hour
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
97pub struct ExportRateLimited {
98    pub error: RateLimited,
99    /// Human-readable explanation naming both timestamps
100    pub message: String,
101    /// When the previous export for this agent was generated
102    pub previous_at: DateTime<Utc>,
103    /// When the next export may be requested
104    pub retry_after_at: DateTime<Utc>,
105}
106
107impl ExportRateLimited {
108    pub fn new(
109        previous_at: DateTime<Utc>,
110        retry_after_at: DateTime<Utc>,
111    ) -> Self {
112        Self {
113            error: RateLimited,
114            message: format!(
115                "An export for this agent was generated at {}. Please retry \
116                 after {}.",
117                previous_at.format("%Y-%m-%d %H:%M UTC"),
118                retry_after_at.format("%Y-%m-%d %H:%M UTC"),
119            ),
120            previous_at,
121            retry_after_at,
122        }
123    }
124}
125
126/// The 400 `file_appeal` answers for a filing that can be fixed: every
127/// problem, so a client can show each one
128#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
129#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
130pub struct AppealRejected {
131    /// Human-readable summary, the same text an MCP client receives
132    pub error: String,
133    /// One entry per problem, in the order found
134    pub problems: Vec<FilingProblem>,
135}
136
137/// The 429 `file_appeal` answers when the agent holds no appeal credit
138#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
139#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
140pub struct AppealCreditsExhausted {
141    /// Human-readable summary, the same text an MCP client receives
142    pub error: String,
143    /// Credits held (zero, or the filing would have gone through)
144    pub balance: u32,
145    /// Most credits an agent can hold
146    pub cap: u32,
147    /// When the next credit arrives: the first of a month, 00:00 UTC
148    pub next_accrual_at: DateTime<Utc>,
149}
150
151/// Any error body the REST API sends. Most are `{"error": "<message>"}`;
152/// a few carry more: a suspension's appeal and export links, when an
153/// export may be retried, every problem with an appeal filing, or an
154/// appeal-credit balance.
155// Untagged, and tried in this order, the plain envelope last: every body
156// has an `error`, so the plain one would match any of them.
157#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
158#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
159#[serde(untagged)]
160pub enum ApiErrorBody {
161    // Boxed: it is several times the size of the others
162    Suspended(Box<BanInfoResponse>),
163    ExportRateLimited(ExportRateLimited),
164    AppealRejected(AppealRejected),
165    AppealCreditsExhausted(AppealCreditsExhausted),
166    Plain(ErrorResponse),
167}
168
169impl ApiErrorBody {
170    /// The human-readable text: the `message` where a body has one, the
171    /// `error` otherwise
172    pub fn message(&self) -> &str {
173        match self {
174            Self::Suspended(b) => &b.message,
175            Self::ExportRateLimited(b) => &b.message,
176            Self::AppealRejected(b) => &b.error,
177            Self::AppealCreditsExhausted(b) => &b.error,
178            Self::Plain(b) => &b.error,
179        }
180    }
181}
182
183impl From<ErrorResponse> for ApiErrorBody {
184    fn from(body: ErrorResponse) -> Self {
185        Self::Plain(body)
186    }
187}
188
189impl From<BanInfoResponse> for ApiErrorBody {
190    fn from(body: BanInfoResponse) -> Self {
191        Self::Suspended(Box::new(body))
192    }
193}
194
195impl From<ExportRateLimited> for ApiErrorBody {
196    fn from(body: ExportRateLimited) -> Self {
197        Self::ExportRateLimited(body)
198    }
199}
200
201impl From<AppealRejected> for ApiErrorBody {
202    fn from(body: AppealRejected) -> Self {
203        Self::AppealRejected(body)
204    }
205}
206
207impl From<AppealCreditsExhausted> for ApiErrorBody {
208    fn from(body: AppealCreditsExhausted) -> Self {
209        Self::AppealCreditsExhausted(body)
210    }
211}
212
213#[cfg(test)]
214mod tests;