1use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum HiveErrorCode {
12 AuthorizationPending,
14 SlowDown,
16 ExpiredToken,
18 AccessDenied,
20 InvalidRequest,
22 Unauthorized,
24 PlanRequired,
26 ForbiddenRight,
28 UploadDisabled,
31 NotFound,
33 InstanceLimit,
35 RevisionConflict,
38 InvalidState,
41 LeaseExpired,
43 ScreeningRequired,
45 RateLimited,
47 UpgradeRequired,
49 InstanceRevoked,
53 QuotaExceeded,
61 PolicyDenied,
65 #[serde(other)]
67 Unknown,
68}
69
70impl HiveErrorCode {
71 pub fn http_status(self) -> u16 {
73 match self {
74 Self::AuthorizationPending
75 | Self::SlowDown
76 | Self::ExpiredToken
77 | Self::AccessDenied
78 | Self::InvalidRequest => 400,
79 Self::Unauthorized | Self::LeaseExpired | Self::InstanceRevoked => 401,
80 Self::PlanRequired => 402,
81 Self::ForbiddenRight | Self::UploadDisabled | Self::PolicyDenied => 403,
82 Self::NotFound => 404,
83 Self::InstanceLimit | Self::RevisionConflict | Self::InvalidState => 409,
84 Self::ScreeningRequired => 422,
85 Self::UpgradeRequired => 426,
86 Self::RateLimited | Self::QuotaExceeded => 429,
89 Self::Unknown => 500,
90 }
91 }
92}
93
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96pub struct HiveErrorDetailV1 {
97 pub code: HiveErrorCode,
98 pub message: String,
100 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub retry_after_s: Option<u64>,
102 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub limit: Option<u64>,
106 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub used: Option<u64>,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
112 pub resource: Option<String>,
113 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub reset_at: Option<String>,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub cooldown_days: Option<u32>,
120 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub member: Option<String>,
123 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub level: Option<String>,
126 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub action: Option<String>,
129 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub expected: Option<u64>,
132 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub actual: Option<u64>,
135 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub min_engine: Option<String>,
138 #[serde(default, skip_serializing_if = "Option::is_none")]
140 pub kind: Option<HiveUploadKind>,
141 #[serde(default, skip_serializing_if = "Option::is_none")]
144 pub reason: Option<String>,
145 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub field: Option<String>,
148 #[serde(default, skip_serializing_if = "Option::is_none")]
150 pub number: Option<u64>,
151 #[serde(default, skip_serializing_if = "Option::is_none")]
155 pub check: Option<String>,
156 #[serde(default, skip_serializing_if = "Option::is_none")]
158 pub role: Option<String>,
159 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub agent_role: Option<String>,
162 #[serde(default, skip_serializing_if = "Option::is_none")]
166 pub feature: Option<String>,
167 #[serde(default, skip_serializing_if = "Option::is_none")]
170 pub plan_needed: Option<String>,
171}
172
173#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
175#[serde(rename_all = "snake_case")]
176pub enum HiveUploadKind {
177 Metrics,
178 Receipts,
179 #[serde(other)]
180 Unknown,
181}
182
183impl HiveErrorDetailV1 {
184 pub fn new(code: HiveErrorCode, message: impl Into<String>) -> Self {
186 Self {
187 code,
188 message: message.into(),
189 retry_after_s: None,
190 limit: None,
191 used: None,
192 resource: None,
193 reset_at: None,
194 cooldown_days: None,
195 member: None,
196 level: None,
197 action: None,
198 expected: None,
199 actual: None,
200 min_engine: None,
201 kind: None,
202 reason: None,
203 field: None,
204 number: None,
205 check: None,
206 role: None,
207 agent_role: None,
208 feature: None,
209 plan_needed: None,
210 }
211 }
212}
213
214#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
216pub struct HiveErrorBody {
217 pub error: HiveErrorDetailV1,
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223
224 #[test]
225 fn unknown_codes_decode_as_unknown() {
226 let body: HiveErrorBody =
227 serde_json::from_str(r#"{"error":{"code":"brand_new","message":"x","extra":1}}"#)
228 .unwrap();
229 assert_eq!(body.error.code, HiveErrorCode::Unknown);
230 }
231
232 #[test]
235 fn instance_revoked_decodes_from_the_c0_fixture() {
236 let body: HiveErrorBody = serde_json::from_str(include_str!(
237 "../../tests/fixtures/hive-v2/routes/instance_revoked.error.json"
238 ))
239 .unwrap();
240 assert_eq!(body.error.code, HiveErrorCode::InstanceRevoked);
241 assert_eq!(body.error.code.http_status(), 401);
242 }
243
244 #[test]
247 fn quota_exceeded_decodes_from_the_c0_fixture() {
248 let body: HiveErrorBody = serde_json::from_str(include_str!(
249 "../../tests/fixtures/hive-v2/routes/quota_exceeded.error.json"
250 ))
251 .unwrap();
252 let e = body.error;
253 assert_eq!(e.code, HiveErrorCode::QuotaExceeded);
254 assert_eq!(e.resource.as_deref(), Some("transfer_bytes"));
255 assert_eq!(e.limit, Some(53_687_091_200));
256 assert_eq!(e.used, Some(53_687_091_200));
257 assert_eq!(e.reset_at.as_deref(), Some("2026-11-01T00:00:00Z"));
258 assert_eq!(e.retry_after_s, Some(86_400));
259 }
260
261 #[test]
265 fn policy_denied_and_cooldown_decode_and_stay_tolerant() {
266 let denied: HiveErrorBody = serde_json::from_str(
267 r#"{"error":{"code":"policy_denied","message":"x","member":"sharing.consent_required_for","level":"org","action":"trust_boundary"}}"#,
268 )
269 .unwrap();
270 assert_eq!(denied.error.code, HiveErrorCode::PolicyDenied);
271 assert_eq!(denied.error.code.http_status(), 403);
272 assert_eq!(
273 denied.error.member.as_deref(),
274 Some("sharing.consent_required_for")
275 );
276 assert_eq!(denied.error.level.as_deref(), Some("org"));
277 assert_eq!(denied.error.action.as_deref(), Some("trust_boundary"));
278 let slot: HiveErrorBody = serde_json::from_str(
279 r#"{"error":{"code":"quota_exceeded","message":"x","resource":"registered_engines","limit":3,"used":3,"reset_at":"2026-11-02T08:00:00Z","cooldown_days":7}}"#,
280 )
281 .unwrap();
282 assert_eq!(slot.error.cooldown_days, Some(7));
283 let newer: HiveErrorBody = serde_json::from_str(
284 r#"{"error":{"code":"quota_exceeded","message":"x","resource":"gpu_minutes","reset_at":null,"extra":1}}"#,
285 )
286 .unwrap();
287 assert_eq!(newer.error.resource.as_deref(), Some("gpu_minutes"));
288 assert_eq!(newer.error.reset_at, None);
289 }
290
291 #[test]
292 fn codes_map_to_their_status() {
293 assert_eq!(HiveErrorCode::PlanRequired.http_status(), 402);
294 assert_eq!(HiveErrorCode::InstanceLimit.http_status(), 409);
295 assert_eq!(HiveErrorCode::UpgradeRequired.http_status(), 426);
296 assert_eq!(HiveErrorCode::UploadDisabled.http_status(), 403);
297 }
298
299 #[test]
300 fn upload_disabled_names_its_kind_and_tolerates_new_ones() {
301 let body: HiveErrorBody = serde_json::from_str(
302 r#"{"error":{"code":"upload_disabled","message":"x","kind":"receipts"}}"#,
303 )
304 .unwrap();
305 assert_eq!(body.error.code, HiveErrorCode::UploadDisabled);
306 assert_eq!(body.error.kind, Some(HiveUploadKind::Receipts));
307 let newer: HiveErrorBody = serde_json::from_str(
308 r#"{"error":{"code":"upload_disabled","message":"x","kind":"traces"}}"#,
309 )
310 .unwrap();
311 assert_eq!(newer.error.kind, Some(HiveUploadKind::Unknown));
312 }
313}