1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
//! Error types for the Cycles client.
use std::time::Duration;
use crate::models::ErrorCode;
/// The error type for all Cycles client operations.
#[derive(Debug, thiserror::Error)]
pub enum Error {
/// HTTP transport error (network failure, timeout, DNS, etc.).
#[error("HTTP transport error: {0}")]
Transport(#[source] reqwest::Error),
/// The server returned an error response.
#[error("API error (HTTP {status}): {message}")]
Api {
/// HTTP status code.
status: u16,
/// Parsed error code from the response body.
code: Option<ErrorCode>,
/// Human-readable error message.
message: String,
/// Server-assigned request ID.
request_id: Option<String>,
/// Suggested retry delay.
retry_after: Option<Duration>,
/// Additional error details.
details: Option<serde_json::Value>,
},
/// Budget is insufficient for the requested operation (HTTP 409).
#[error("budget exceeded: {message}")]
BudgetExceeded {
/// Human-readable error message.
message: String,
/// Scopes that are over budget.
affected_scopes: Vec<String>,
/// Suggested retry delay.
retry_after: Option<Duration>,
/// Server-assigned request ID.
request_id: Option<String>,
/// HTTP status of the response this error was classified from.
/// `None` when the error was derived from a `DENY` decision on an
/// HTTP 200 reservation response rather than an error response.
status: Option<u16>,
},
/// A commit hit `RESERVATION_EXPIRED` and the event-fallback recovery
/// (`POST /v1/events`) also failed, so the spend is **not** recorded.
///
/// When a commit lands after the reservation's grace period the server
/// has already returned the reserved budget to the pool; the client then
/// tries to record the spend as a post-hoc direct-debit event (same
/// idempotency key as the commit). This variant is returned only when
/// that fallback fails too. Both underlying errors are preserved so the
/// caller can see why the commit expired *and* why recovery failed.
#[error(
"commit failed: reservation {reservation_id} expired before the commit landed and the \
event fallback also failed — spend is NOT recorded (commit error: {commit_error}; \
event error: {event_error})"
)]
CommitRecoveryFailed {
/// The reservation whose commit expired.
reservation_id: String,
/// The original `RESERVATION_EXPIRED` commit error.
commit_error: Box<Error>,
/// The error from the failed `POST /v1/events` fallback.
event_error: Box<Error>,
},
/// Failed to deserialize the response body.
#[error("failed to deserialize response: {0}")]
Deserialization(#[source] serde_json::Error),
/// Invalid client configuration.
#[error("invalid configuration: {0}")]
Config(String),
/// Invalid request parameters (caught before sending).
#[error("invalid request: {0}")]
Validation(String),
}
impl Error {
/// Returns `true` if the error is retryable.
///
/// Transport errors, server errors (5xx), and rate limiting (HTTP 429)
/// are retryable. 429 is retryable **by status alone** — even when the
/// body is absent or unparseable (no typed error code), the status is
/// authoritative and any `Retry-After` header is still honored — matching
/// the other Cycles SDKs. Budget exceeded errors are only retryable when
/// they came from an actual 429 rate-limit response carrying a retry
/// delay; a 409 `BUDGET_EXCEEDED` (or a `DENY` decision) is a budget
/// fact, not a transient fault, regardless of any suggested delay.
pub fn is_retryable(&self) -> bool {
match self {
Self::Transport(_) => true,
Self::Api { status, code, .. } => {
if *status >= 500 || *status == 429 {
return true;
}
code.is_some_and(|c| c.is_retryable())
}
Self::BudgetExceeded {
retry_after,
status,
..
} => retry_after.is_some() && *status == Some(429),
// Final by construction: both the commit path (including its
// inline retry) and the event fallback (including its own bounded
// retry) have already run to completion.
Self::CommitRecoveryFailed { .. } => false,
Self::Deserialization(_) | Self::Config(_) | Self::Validation(_) => false,
}
}
/// Returns `true` if this is an authentication/authorization failure
/// (HTTP 401 `UNAUTHORIZED` or HTTP 403 `FORBIDDEN`).
///
/// These are deliberately **non-retryable**: retrying with the same
/// credentials cannot succeed. Callers should treat them as
/// configuration problems (rotate/fix the API key, check the principal's
/// permissions) rather than transient faults.
pub fn is_auth_error(&self) -> bool {
matches!(
self,
Self::Api {
status: 401 | 403,
..
} | Self::Api {
code: Some(ErrorCode::Unauthorized | ErrorCode::Forbidden),
..
}
)
}
/// Returns `true` if this is a budget exceeded error.
pub fn is_budget_exceeded(&self) -> bool {
matches!(self, Self::BudgetExceeded { .. })
|| matches!(
self,
Self::Api {
code: Some(ErrorCode::BudgetExceeded),
..
}
)
}
/// Returns `true` if this is a tenant-closed error (`TENANT_CLOSED`).
///
/// Servers return HTTP 409 `TENANT_CLOSED` on reservation
/// create/commit/release/extend when the owning tenant's status is
/// CLOSED (runtime spec v0.1.25.13, mirroring governance spec Rule 2).
/// Not retryable — the tenant must be reopened administratively.
pub fn is_tenant_closed(&self) -> bool {
matches!(
self,
Self::Api {
code: Some(ErrorCode::TenantClosed),
..
}
)
}
/// Returns the HTTP status code of the response this error was built
/// from, if any.
///
/// `None` for errors that did not come from an HTTP error response
/// (transport failures, client-side validation, and `BudgetExceeded`
/// derived from a `DENY` decision on an HTTP 200 response).
pub fn status(&self) -> Option<u16> {
match self {
Self::Api { status, .. } => Some(*status),
Self::BudgetExceeded { status, .. } => *status,
_ => None,
}
}
/// Returns the suggested retry delay, if any.
pub fn retry_after(&self) -> Option<Duration> {
match self {
Self::Api { retry_after, .. } => *retry_after,
Self::BudgetExceeded { retry_after, .. } => *retry_after,
_ => None,
}
}
/// Returns the server-assigned request ID, if available.
pub fn request_id(&self) -> Option<&str> {
match self {
Self::Api { request_id, .. } => request_id.as_deref(),
Self::BudgetExceeded { request_id, .. } => request_id.as_deref(),
_ => None,
}
}
/// Returns the error code, if available.
pub fn error_code(&self) -> Option<ErrorCode> {
match self {
Self::Api { code, .. } => *code,
Self::BudgetExceeded { .. } => Some(ErrorCode::BudgetExceeded),
_ => None,
}
}
}
impl From<reqwest::Error> for Error {
fn from(err: reqwest::Error) -> Self {
Self::Transport(err)
}
}