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