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,
37 InvalidState,
40 LeaseExpired,
42 ScreeningRequired,
44 RateLimited,
46 UpgradeRequired,
48 InstanceRevoked,
52 QuotaExceeded,
60 PolicyDenied,
64 #[serde(other)]
66 Unknown,
67}
68
69impl HiveErrorCode {
70 pub fn http_status(self) -> u16 {
72 match self {
73 Self::AuthorizationPending
74 | Self::SlowDown
75 | Self::ExpiredToken
76 | Self::AccessDenied
77 | Self::InvalidRequest => 400,
78 Self::Unauthorized | Self::LeaseExpired | Self::InstanceRevoked => 401,
79 Self::PlanRequired => 402,
80 Self::ForbiddenRight | Self::UploadDisabled | Self::PolicyDenied => 403,
81 Self::NotFound => 404,
82 Self::InstanceLimit | Self::RevisionConflict | Self::InvalidState => 409,
83 Self::ScreeningRequired => 422,
84 Self::UpgradeRequired => 426,
85 Self::RateLimited | Self::QuotaExceeded => 429,
88 Self::Unknown => 500,
89 }
90 }
91}
92
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
95pub struct HiveErrorDetailV1 {
96 pub code: HiveErrorCode,
97 pub message: String,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub retry_after_s: Option<u64>,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub limit: Option<u64>,
105 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub used: Option<u64>,
108 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub resource: Option<String>,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub reset_at: Option<String>,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub cooldown_days: Option<u32>,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub member: Option<String>,
122 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub level: Option<String>,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub action: Option<String>,
128 #[serde(default, skip_serializing_if = "Option::is_none")]
130 pub expected: Option<u64>,
131 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub actual: Option<u64>,
134 #[serde(default, skip_serializing_if = "Option::is_none")]
136 pub min_instance: Option<String>,
137 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub kind: Option<HiveUploadKind>,
140 #[serde(default, skip_serializing_if = "Option::is_none")]
143 pub reason: Option<String>,
144 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub field: Option<String>,
147 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub number: Option<u64>,
150 #[serde(default, skip_serializing_if = "Option::is_none")]
154 pub check: Option<String>,
155 #[serde(default, skip_serializing_if = "Option::is_none")]
157 pub role: Option<String>,
158 #[serde(default, skip_serializing_if = "Option::is_none")]
160 pub agent_role: Option<String>,
161 #[serde(default, skip_serializing_if = "Option::is_none")]
165 pub feature: Option<String>,
166 #[serde(default, skip_serializing_if = "Option::is_none")]
169 pub plan_needed: Option<String>,
170}
171
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
174#[serde(rename_all = "snake_case")]
175pub enum HiveUploadKind {
176 Metrics,
177 Receipts,
178 #[serde(other)]
179 Unknown,
180}
181
182impl HiveErrorDetailV1 {
183 pub fn new(code: HiveErrorCode, message: impl Into<String>) -> Self {
185 Self {
186 code,
187 message: message.into(),
188 retry_after_s: None,
189 limit: None,
190 used: None,
191 resource: None,
192 reset_at: None,
193 cooldown_days: None,
194 member: None,
195 level: None,
196 action: None,
197 expected: None,
198 actual: None,
199 min_instance: None,
200 kind: None,
201 reason: None,
202 field: None,
203 number: None,
204 check: None,
205 role: None,
206 agent_role: None,
207 feature: None,
208 plan_needed: None,
209 }
210 }
211}
212
213#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
215pub struct HiveErrorBody {
216 pub error: HiveErrorDetailV1,
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222
223 #[test]
224 fn unknown_codes_decode_as_unknown() {
225 let body: HiveErrorBody =
226 serde_json::from_str(r#"{"error":{"code":"brand_new","message":"x","extra":1}}"#)
227 .unwrap();
228 assert_eq!(body.error.code, HiveErrorCode::Unknown);
229 }
230
231 #[test]
234 fn instance_revoked_decodes_from_the_c0_fixture() {
235 let body: HiveErrorBody = serde_json::from_str(include_str!(
236 "../../tests/fixtures/hive-v2/routes/instance_revoked.error.json"
237 ))
238 .unwrap();
239 assert_eq!(body.error.code, HiveErrorCode::InstanceRevoked);
240 assert_eq!(body.error.code.http_status(), 401);
241 }
242
243 #[test]
246 fn quota_exceeded_decodes_from_the_c0_fixture() {
247 let body: HiveErrorBody = serde_json::from_str(include_str!(
248 "../../tests/fixtures/hive-v2/routes/quota_exceeded.error.json"
249 ))
250 .unwrap();
251 let e = body.error;
252 assert_eq!(e.code, HiveErrorCode::QuotaExceeded);
253 assert_eq!(e.resource.as_deref(), Some("transfer_bytes"));
254 assert_eq!(e.limit, Some(53_687_091_200));
255 assert_eq!(e.used, Some(53_687_091_200));
256 assert_eq!(e.reset_at.as_deref(), Some("2026-11-01T00:00:00Z"));
257 assert_eq!(e.retry_after_s, Some(86_400));
258 }
259
260 #[test]
264 fn policy_denied_and_cooldown_decode_and_stay_tolerant() {
265 let denied: HiveErrorBody = serde_json::from_str(
266 r#"{"error":{"code":"policy_denied","message":"x","member":"sharing.consent_required_for","level":"org","action":"trust_boundary"}}"#,
267 )
268 .unwrap();
269 assert_eq!(denied.error.code, HiveErrorCode::PolicyDenied);
270 assert_eq!(denied.error.code.http_status(), 403);
271 assert_eq!(
272 denied.error.member.as_deref(),
273 Some("sharing.consent_required_for")
274 );
275 assert_eq!(denied.error.level.as_deref(), Some("org"));
276 assert_eq!(denied.error.action.as_deref(), Some("trust_boundary"));
277 let slot: HiveErrorBody = serde_json::from_str(
278 r#"{"error":{"code":"quota_exceeded","message":"x","resource":"registered_instances","limit":3,"used":3,"reset_at":"2026-11-02T08:00:00Z","cooldown_days":7}}"#,
279 )
280 .unwrap();
281 assert_eq!(slot.error.cooldown_days, Some(7));
282 let newer: HiveErrorBody = serde_json::from_str(
283 r#"{"error":{"code":"quota_exceeded","message":"x","resource":"gpu_minutes","reset_at":null,"extra":1}}"#,
284 )
285 .unwrap();
286 assert_eq!(newer.error.resource.as_deref(), Some("gpu_minutes"));
287 assert_eq!(newer.error.reset_at, None);
288 }
289
290 #[test]
291 fn codes_map_to_their_status() {
292 assert_eq!(HiveErrorCode::PlanRequired.http_status(), 402);
293 assert_eq!(HiveErrorCode::InstanceLimit.http_status(), 409);
294 assert_eq!(HiveErrorCode::UpgradeRequired.http_status(), 426);
295 assert_eq!(HiveErrorCode::UploadDisabled.http_status(), 403);
296 }
297
298 #[test]
299 fn upload_disabled_names_its_kind_and_tolerates_new_ones() {
300 let body: HiveErrorBody = serde_json::from_str(
301 r#"{"error":{"code":"upload_disabled","message":"x","kind":"receipts"}}"#,
302 )
303 .unwrap();
304 assert_eq!(body.error.code, HiveErrorCode::UploadDisabled);
305 assert_eq!(body.error.kind, Some(HiveUploadKind::Receipts));
306 let newer: HiveErrorBody = serde_json::from_str(
307 r#"{"error":{"code":"upload_disabled","message":"x","kind":"traces"}}"#,
308 )
309 .unwrap();
310 assert_eq!(newer.error.kind, Some(HiveUploadKind::Unknown));
311 }
312}