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 FeatureDisabled,
37 InstanceLimit,
39 RevisionConflict,
41 InvalidState,
44 LeaseExpired,
46 ScreeningRequired,
48 RateLimited,
50 UpgradeRequired,
52 InstanceRevoked,
56 QuotaExceeded,
64 PolicyDenied,
68 #[serde(other)]
70 Unknown,
71}
72
73impl HiveErrorCode {
74 pub fn http_status(self) -> u16 {
76 match self {
77 Self::AuthorizationPending
78 | Self::SlowDown
79 | Self::ExpiredToken
80 | Self::AccessDenied
81 | Self::InvalidRequest => 400,
82 Self::Unauthorized | Self::LeaseExpired | Self::InstanceRevoked => 401,
83 Self::PlanRequired => 402,
84 Self::ForbiddenRight | Self::UploadDisabled | Self::PolicyDenied => 403,
85 Self::NotFound | Self::FeatureDisabled => 404,
86 Self::InstanceLimit | Self::RevisionConflict | Self::InvalidState => 409,
87 Self::ScreeningRequired => 422,
88 Self::UpgradeRequired => 426,
89 Self::RateLimited | Self::QuotaExceeded => 429,
92 Self::Unknown => 500,
93 }
94 }
95}
96
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
99pub struct HiveErrorDetailV1 {
100 pub code: HiveErrorCode,
101 pub message: String,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub retry_after_s: Option<u64>,
105 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub limit: Option<u64>,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub used: Option<u64>,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub resource: Option<String>,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub reset_at: Option<String>,
120 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub cooldown_days: Option<u32>,
123 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub member: Option<String>,
126 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub level: Option<String>,
129 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub action: Option<String>,
132 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub expected: Option<u64>,
135 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub actual: Option<u64>,
138 #[serde(default, skip_serializing_if = "Option::is_none")]
140 pub min_instance: Option<String>,
141 #[serde(default, skip_serializing_if = "Option::is_none")]
143 pub kind: Option<HiveUploadKind>,
144 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub reason: Option<String>,
148 #[serde(default, skip_serializing_if = "Option::is_none")]
150 pub field: Option<String>,
151 #[serde(default, skip_serializing_if = "Option::is_none")]
153 pub number: Option<u64>,
154 #[serde(default, skip_serializing_if = "Option::is_none")]
158 pub check: Option<String>,
159 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub role: Option<String>,
162 #[serde(default, skip_serializing_if = "Option::is_none")]
164 pub agent_role: Option<String>,
165 #[serde(default, skip_serializing_if = "Option::is_none")]
171 pub feature: Option<String>,
172 #[serde(default, skip_serializing_if = "Option::is_none")]
175 pub plan_needed: Option<String>,
176}
177
178#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
180#[serde(rename_all = "snake_case")]
181pub enum HiveUploadKind {
182 Metrics,
183 Receipts,
184 #[serde(other)]
185 Unknown,
186}
187
188impl HiveErrorDetailV1 {
189 pub fn new(code: HiveErrorCode, message: impl Into<String>) -> Self {
191 Self {
192 code,
193 message: message.into(),
194 retry_after_s: None,
195 limit: None,
196 used: None,
197 resource: None,
198 reset_at: None,
199 cooldown_days: None,
200 member: None,
201 level: None,
202 action: None,
203 expected: None,
204 actual: None,
205 min_instance: None,
206 kind: None,
207 reason: None,
208 field: None,
209 number: None,
210 check: None,
211 role: None,
212 agent_role: None,
213 feature: None,
214 plan_needed: None,
215 }
216 }
217}
218
219#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
221pub struct HiveErrorBody {
222 pub error: HiveErrorDetailV1,
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228
229 #[test]
230 fn unknown_codes_decode_as_unknown() {
231 let body: HiveErrorBody =
232 serde_json::from_str(r#"{"error":{"code":"brand_new","message":"x","extra":1}}"#)
233 .unwrap();
234 assert_eq!(body.error.code, HiveErrorCode::Unknown);
235 }
236
237 #[test]
240 fn feature_disabled_decodes_with_its_feature() {
241 let body: HiveErrorBody = serde_json::from_str(
242 r#"{"error":{"code":"feature_disabled","message":"this feature is switched off on this Hive","feature":"instance_metrics"}}"#,
243 )
244 .unwrap();
245 assert_eq!(body.error.code, HiveErrorCode::FeatureDisabled);
246 assert_eq!(body.error.code.http_status(), 404);
247 assert_eq!(body.error.feature.as_deref(), Some("instance_metrics"));
248 }
249
250 #[test]
253 fn instance_revoked_decodes_from_the_c0_fixture() {
254 let body: HiveErrorBody = serde_json::from_str(include_str!(
255 "../../tests/fixtures/hive-v2/routes/instance_revoked.error.json"
256 ))
257 .unwrap();
258 assert_eq!(body.error.code, HiveErrorCode::InstanceRevoked);
259 assert_eq!(body.error.code.http_status(), 401);
260 }
261
262 #[test]
265 fn quota_exceeded_decodes_from_the_c0_fixture() {
266 let body: HiveErrorBody = serde_json::from_str(include_str!(
267 "../../tests/fixtures/hive-v2/routes/quota_exceeded.error.json"
268 ))
269 .unwrap();
270 let e = body.error;
271 assert_eq!(e.code, HiveErrorCode::QuotaExceeded);
272 assert_eq!(e.resource.as_deref(), Some("transfer_bytes"));
273 assert_eq!(e.limit, Some(53_687_091_200));
274 assert_eq!(e.used, Some(53_687_091_200));
275 assert_eq!(e.reset_at.as_deref(), Some("2026-11-01T00:00:00Z"));
276 assert_eq!(e.retry_after_s, Some(86_400));
277 }
278
279 #[test]
283 fn policy_denied_and_cooldown_decode_and_stay_tolerant() {
284 let denied: HiveErrorBody = serde_json::from_str(
285 r#"{"error":{"code":"policy_denied","message":"x","member":"sharing.consent_required_for","level":"org","action":"trust_boundary"}}"#,
286 )
287 .unwrap();
288 assert_eq!(denied.error.code, HiveErrorCode::PolicyDenied);
289 assert_eq!(denied.error.code.http_status(), 403);
290 assert_eq!(
291 denied.error.member.as_deref(),
292 Some("sharing.consent_required_for")
293 );
294 assert_eq!(denied.error.level.as_deref(), Some("org"));
295 assert_eq!(denied.error.action.as_deref(), Some("trust_boundary"));
296 let slot: HiveErrorBody = serde_json::from_str(
297 r#"{"error":{"code":"quota_exceeded","message":"x","resource":"registered_instances","limit":3,"used":3,"reset_at":"2026-11-02T08:00:00Z","cooldown_days":7}}"#,
298 )
299 .unwrap();
300 assert_eq!(slot.error.cooldown_days, Some(7));
301 let newer: HiveErrorBody = serde_json::from_str(
302 r#"{"error":{"code":"quota_exceeded","message":"x","resource":"gpu_minutes","reset_at":null,"extra":1}}"#,
303 )
304 .unwrap();
305 assert_eq!(newer.error.resource.as_deref(), Some("gpu_minutes"));
306 assert_eq!(newer.error.reset_at, None);
307 }
308
309 #[test]
310 fn codes_map_to_their_status() {
311 assert_eq!(HiveErrorCode::PlanRequired.http_status(), 402);
312 assert_eq!(HiveErrorCode::InstanceLimit.http_status(), 409);
313 assert_eq!(HiveErrorCode::UpgradeRequired.http_status(), 426);
314 assert_eq!(HiveErrorCode::UploadDisabled.http_status(), 403);
315 }
316
317 #[test]
318 fn upload_disabled_names_its_kind_and_tolerates_new_ones() {
319 let body: HiveErrorBody = serde_json::from_str(
320 r#"{"error":{"code":"upload_disabled","message":"x","kind":"receipts"}}"#,
321 )
322 .unwrap();
323 assert_eq!(body.error.code, HiveErrorCode::UploadDisabled);
324 assert_eq!(body.error.kind, Some(HiveUploadKind::Receipts));
325 let newer: HiveErrorBody = serde_json::from_str(
326 r#"{"error":{"code":"upload_disabled","message":"x","kind":"traces"}}"#,
327 )
328 .unwrap();
329 assert_eq!(newer.error.kind, Some(HiveUploadKind::Unknown));
330 }
331}