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    /// 409 with `limit`/`used`: every instance seat is taken.
34    InstanceLimit,
35    /// 409 with `expected`/`actual`: knowledge moved on.
36    RevisionConflict,
37    /// 409 with `reason` (1.5): the object is not in a state that allows
38    /// this.
39    InvalidState,
40    /// 401: the instance lease is past `grace_until`; renew or reconnect.
41    LeaseExpired,
42    /// 422: the service screening refused content the engine should have removed.
43    ScreeningRequired,
44    /// 429 with `retry_after_s`.
45    RateLimited,
46    /// 426 with `min_instance` (also in the `Hive-Min-Instance` header).
47    UpgradeRequired,
48    /// 401 with `revocation` (C0 2.0 E12, §3.17.3): a workspace admin
49    /// revoked this instance; every engine route answers it until a new
50    /// connect. Never retried.
51    InstanceRevoked,
52    /// Release B quotas (Hive API §8.1): `resource`, `limit`, `used`,
53    /// `reset_at`. Counted resources (`registered_instances`,
54    /// `private_vaults`) answer 409 (`registered_instances` in Personal adds
55    /// `cooldown_days`, `reset_at` = when a cooling-down slot frees);
56    /// monthly ones (`transfer_bytes`, `bus_messages`) answer 429 with
57    /// `retry_after_s` and `Retry-After` until `reset_at`. Never overage:
58    /// the engine waits, its outbox keeps local work.
59    QuotaExceeded,
60    /// 403 with `member` (dotted policy path) and `level` (the outermost
61    /// level whose document refuses; consent refusals add `action`): Hive
62    /// itself enforces an `x-enforcement: server` policy member (§7.2).
63    PolicyDenied,
64    /// Any code this engine does not know.
65    #[serde(other)]
66    Unknown,
67}
68
69impl HiveErrorCode {
70    /// HTTP status the service answers with for this code.
71    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            // 409 for counted resources; the monthly ones, the common case
86            // on data routes, are 429.
87            Self::RateLimited | Self::QuotaExceeded => 429,
88            Self::Unknown => 500,
89        }
90    }
91}
92
93/// The `error` object.
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
95pub struct HiveErrorDetailV1 {
96    pub code: HiveErrorCode,
97    /// Human-readable, safe to show; never contains secrets or tokens.
98    pub message: String,
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub retry_after_s: Option<u64>,
101    /// `instance_limit`: seats in the plan; `quota_exceeded`: the quota
102    /// (bytes for `transfer_bytes`, so wider than 32 bits).
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub limit: Option<u64>,
105    /// `instance_limit`: seats in use; `quota_exceeded`: what is used.
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub used: Option<u64>,
108    /// `quota_exceeded`: the quota (`registered_instances`, `private_vaults`,
109    /// `transfer_bytes`, `bus_messages`, or one a newer Hive adds).
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub resource: Option<String>,
112    /// `quota_exceeded`: when it frees (RFC 3339; `null` when nothing frees
113    /// by itself).
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub reset_at: Option<String>,
116    /// `quota_exceeded` `registered_instances`: the Personal slot cool-down.
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub cooldown_days: Option<u32>,
119    /// `policy_denied`: the dotted member path that refused.
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub member: Option<String>,
122    /// `policy_denied`: the outermost level whose document refuses.
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub level: Option<String>,
125    /// `policy_denied` of `consent_required_for`: the action.
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub action: Option<String>,
128    /// `revision_conflict`: revision the client expected.
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub expected: Option<u64>,
131    /// `revision_conflict`: current revision.
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub actual: Option<u64>,
134    /// `upgrade_required`: lowest engine version accepted.
135    #[serde(default, skip_serializing_if = "Option::is_none")]
136    pub min_instance: Option<String>,
137    /// `upload_disabled`: which upload the organization switched off.
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    pub kind: Option<HiveUploadKind>,
140    /// 1.5: machine-readable detail of the code (`[a-z_]`, ≤ 64), e.g.
141    /// `required`.
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub reason: Option<String>,
144    /// 1.5: the request field the error is about (`invalid_request`).
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub field: Option<String>,
147    /// 1.6: the proposal an error is about (`proposal_state`, `not_found`).
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub number: Option<u64>,
150    /// `not_permitted` (Hive task #17): the check that failed, `presence`
151    /// (the agent has no live presence on the bus) or `role` (present in
152    /// another role).
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    pub check: Option<String>,
155    /// `not_permitted`: the task's role.
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    pub role: Option<String>,
158    /// `not_permitted` with `check: role`: the role the agent is present in.
159    #[serde(default, skip_serializing_if = "Option::is_none")]
160    pub agent_role: Option<String>,
161    /// 2.x `plan_required`: the feature the plan lacks (`repos`,
162    /// `internal_vaults`, …; absent from a 1.x Hive, where it means Hive
163    /// itself). `limit`/`used` carry a counted feature's numbers.
164    #[serde(default, skip_serializing_if = "Option::is_none")]
165    pub feature: Option<String>,
166    /// 2.x `plan_required`: the lowest plan that includes `feature`
167    /// (`pro`, `team`, `enterprise`).
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    pub plan_needed: Option<String>,
170}
171
172/// An upload an organization can switch off (1.4).
173#[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    /// An error with only code and message.
184    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/// Body of every non-2xx response.
214#[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    /// C0 2.0 `routes/instance_revoked.error.json` decodes to its own code
232    /// (dogfood 25.10.: it decoded as `unknown`, shown as "401 (unknown)").
233    #[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    /// C0 2.x `routes/quota_exceeded.error.json` (Release B §8.1): a byte
244    /// quota wider than 32 bits decodes, with resource and reset.
245    #[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    /// The server's own shapes (API §7.2 and §3): `policy_denied {member,
261    /// level, action?}` and the Personal slot cool-down; a 1.x engine that does
262    /// not know them still decodes the body (unknown code, extra fields).
263    #[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}