Skip to main content

lean_ctx_protocol/hive/
error.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Error body of every non-2xx Hive response: `{"error":{...}}`.
4
5use serde::{Deserialize, Serialize};
6
7/// Machine-readable error code. Unknown codes from a newer service decode as
8/// [`HiveErrorCode::Unknown`] and are handled by HTTP status.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum HiveErrorCode {
12    /// 400, device flow: the user has not approved yet; poll again after `interval`.
13    AuthorizationPending,
14    /// 400, device flow: polling too fast; add 5 s to the interval.
15    SlowDown,
16    /// 400, device flow: the device code expired; start again.
17    ExpiredToken,
18    /// 400, device flow: the user refused.
19    AccessDenied,
20    /// 400: body failed validation or carried unknown fields.
21    InvalidRequest,
22    /// 401: access token missing, expired or not for this instance.
23    Unauthorized,
24    /// 402: the organization's plan does not include Hive.
25    PlanRequired,
26    /// 403: the lease lacks the right this route needs.
27    ForbiddenRight,
28    /// 403 with `kind` (1.4): the organization switched this upload off.
29    /// Stop that kind of upload and drop its queue until a lease allows it.
30    UploadDisabled,
31    /// 404: unknown instance, vault, proposal or lease.
32    NotFound,
33    /// 404 with `feature` (Hive API.md "feature switches"): the operator
34    /// switched this feature off on this Hive (`instance_metrics` while
35    /// `HIVE_ENGINE_METRICS` is off). The route exists; try again much later.
36    FeatureDisabled,
37    /// 409 with `limit`/`used`: every instance seat is taken.
38    InstanceLimit,
39    /// 409 with `expected`/`actual`: knowledge moved on.
40    RevisionConflict,
41    /// 409 with `reason` (1.5): the object is not in a state that allows
42    /// this.
43    InvalidState,
44    /// 401: the instance lease is past `grace_until`; renew or reconnect.
45    LeaseExpired,
46    /// 422: the service screening refused content the engine should have removed.
47    ScreeningRequired,
48    /// 429 with `retry_after_s`.
49    RateLimited,
50    /// 426 with `min_instance` (also in the `Hive-Min-Instance` header).
51    UpgradeRequired,
52    /// 401 with `revocation` (C0 2.0 E12, §3.17.3): a workspace admin
53    /// revoked this instance; every engine route answers it until a new
54    /// connect. Never retried.
55    InstanceRevoked,
56    /// Release B quotas (Hive API §8.1): `resource`, `limit`, `used`,
57    /// `reset_at`. Counted resources (`registered_instances`,
58    /// `private_vaults`) answer 409 (`registered_instances` in Personal adds
59    /// `cooldown_days`, `reset_at` = when a cooling-down slot frees);
60    /// monthly ones (`transfer_bytes`, `bus_messages`) answer 429 with
61    /// `retry_after_s` and `Retry-After` until `reset_at`. Never overage:
62    /// the engine waits, its outbox keeps local work.
63    QuotaExceeded,
64    /// 403 with `member` (dotted policy path) and `level` (the outermost
65    /// level whose document refuses; consent refusals add `action`): Hive
66    /// itself enforces an `x-enforcement: server` policy member (§7.2).
67    PolicyDenied,
68    /// Any code this engine does not know.
69    #[serde(other)]
70    Unknown,
71}
72
73impl HiveErrorCode {
74    /// HTTP status the service answers with for this code.
75    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            // 409 for counted resources; the monthly ones, the common case
90            // on data routes, are 429.
91            Self::RateLimited | Self::QuotaExceeded => 429,
92            Self::Unknown => 500,
93        }
94    }
95}
96
97/// The `error` object.
98#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
99pub struct HiveErrorDetailV1 {
100    pub code: HiveErrorCode,
101    /// Human-readable, safe to show; never contains secrets or tokens.
102    pub message: String,
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub retry_after_s: Option<u64>,
105    /// `instance_limit`: seats in the plan; `quota_exceeded`: the quota
106    /// (bytes for `transfer_bytes`, so wider than 32 bits).
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub limit: Option<u64>,
109    /// `instance_limit`: seats in use; `quota_exceeded`: what is used.
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub used: Option<u64>,
112    /// `quota_exceeded`: the quota (`registered_instances`, `private_vaults`,
113    /// `transfer_bytes`, `bus_messages`, or one a newer Hive adds).
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub resource: Option<String>,
116    /// `quota_exceeded`: when it frees (RFC 3339; `null` when nothing frees
117    /// by itself).
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub reset_at: Option<String>,
120    /// `quota_exceeded` `registered_instances`: the Personal slot cool-down.
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub cooldown_days: Option<u32>,
123    /// `policy_denied`: the dotted member path that refused.
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub member: Option<String>,
126    /// `policy_denied`: the outermost level whose document refuses.
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub level: Option<String>,
129    /// `policy_denied` of `consent_required_for`: the action.
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub action: Option<String>,
132    /// `revision_conflict`: revision the client expected.
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub expected: Option<u64>,
135    /// `revision_conflict`: current revision.
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub actual: Option<u64>,
138    /// `upgrade_required`: lowest engine version accepted.
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub min_instance: Option<String>,
141    /// `upload_disabled`: which upload the organization switched off.
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub kind: Option<HiveUploadKind>,
144    /// 1.5: machine-readable detail of the code (`[a-z_]`, ≤ 64), e.g.
145    /// `required`.
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub reason: Option<String>,
148    /// 1.5: the request field the error is about (`invalid_request`).
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub field: Option<String>,
151    /// 1.6: the proposal an error is about (`proposal_state`, `not_found`).
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub number: Option<u64>,
154    /// `not_permitted` (Hive task #17): the check that failed, `presence`
155    /// (the agent has no live presence on the bus) or `role` (present in
156    /// another role).
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    pub check: Option<String>,
159    /// `not_permitted`: the task's role.
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    pub role: Option<String>,
162    /// `not_permitted` with `check: role`: the role the agent is present in.
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub agent_role: Option<String>,
165    /// 2.x `plan_required`: the feature the plan lacks (`repos`,
166    /// `internal_vaults`, …; absent from a 1.x Hive, where it means Hive
167    /// itself). `limit`/`used` carry a counted feature's numbers.
168    /// `feature_disabled`: the feature switched off on this Hive
169    /// (`instance_metrics`).
170    #[serde(default, skip_serializing_if = "Option::is_none")]
171    pub feature: Option<String>,
172    /// 2.x `plan_required`: the lowest plan that includes `feature`
173    /// (`pro`, `team`, `enterprise`).
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub plan_needed: Option<String>,
176}
177
178/// An upload an organization can switch off (1.4).
179#[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    /// An error with only code and message.
190    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/// Body of every non-2xx response.
220#[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    /// Hive answers `POST /v1/metrics/hours` with this while
238    /// `HIVE_ENGINE_METRICS` is off (dogfood 05.11.: decoded as `unknown`).
239    #[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    /// C0 2.0 `routes/instance_revoked.error.json` decodes to its own code
251    /// (dogfood 25.10.: it decoded as `unknown`, shown as "401 (unknown)").
252    #[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    /// C0 2.x `routes/quota_exceeded.error.json` (Release B §8.1): a byte
263    /// quota wider than 32 bits decodes, with resource and reset.
264    #[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    /// The server's own shapes (API §7.2 and §3): `policy_denied {member,
280    /// level, action?}` and the Personal slot cool-down; a 1.x engine that does
281    /// not know them still decodes the body (unknown code, extra fields).
282    #[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}