1use agentos_sidecar_client::{ProtocolCodecError, TransportError};
8
9#[derive(Debug, Clone, Default, serde::Serialize)]
12#[serde(rename_all = "camelCase")]
13pub struct ResourceLimitDetails {
14 pub limit_name: Option<String>,
15 pub configured_limit: Option<u64>,
16 pub current_usage: Option<u64>,
17 pub requested: Option<u64>,
18 pub unit: Option<String>,
19 pub scope: Option<String>,
20 pub vm_id: Option<String>,
21 pub session_generation: Option<u64>,
22 pub capability_id: Option<u64>,
23 pub operation: Option<String>,
24 pub configuration_path: Option<String>,
25 pub retryable: Option<bool>,
26 pub errno: Option<String>,
27}
28
29#[derive(thiserror::Error, Debug, Clone)]
31pub enum ClientError {
32 #[error("Path must be absolute: {0}")]
38 PathNotAbsolute(String),
39
40 #[error("Path must be normalized: {0}")]
44 PathNotNormalized(String),
45
46 #[error("Path is read-only: {0}")]
50 PathReadOnly(String),
51
52 #[error("Process not found: {0}")]
58 ProcessNotFound(u32),
59
60 #[error("timeout: process {process_id} exceeded its execution deadline and was stopped")]
62 ExecutionTimedOut { process_id: String },
63
64 #[error("timeout: {message}")]
67 OperationTimedOut {
68 message: String,
69 details: Box<ResourceLimitDetails>,
70 },
71
72 #[error("termination_failed: process {process_id} could not be confirmed stopped: {reason}")]
74 TerminationFailed { process_id: String, reason: String },
75
76 #[error("shell not found: {0}")]
78 ShellNotFound(String),
79
80 #[error("kernel error [{code}]: {message}")]
84 Kernel { code: String, message: String },
85
86 #[error("resource limit [{code}]: {message}")]
89 ResourceLimit {
90 code: String,
91 message: String,
92 details: Box<ResourceLimitDetails>,
93 },
94
95 #[error("invalid config: {0}")]
97 InvalidConfig(String),
98
99 #[error("invalid schedule: {0}")]
101 InvalidSchedule(String),
102
103 #[error("schedule is in the past: {0}")]
105 PastSchedule(String),
106
107 #[error("invalid package source: {0}")]
109 InvalidPackageSource(String),
110
111 #[error("package download failed: {0}")]
113 PackageDownload(String),
114
115 #[error(
117 "package is {observed} bytes; limit is {limit}; raise package_resolver.max_package_bytes"
118 )]
119 PackageTooLarge { observed: u64, limit: u64 },
120
121 #[error("package digest mismatch: expected {expected}, got {actual}")]
123 PackageDigestMismatch { expected: String, actual: String },
124
125 #[error("invalid .aospkg: {0}")]
127 InvalidPackageFormat(String),
128
129 #[error("package I/O failed: {0}")]
131 PackageIo(String),
132
133 #[error("package acquisition [{code}]: {message}")]
138 PackageAcquisition {
139 code: String,
140 message: String,
141 details: Box<ResourceLimitDetails>,
142 },
143
144 #[error(
147 "package cache capacity exceeded: requested {requested} bytes with {current} of {limit} bytes in use; raise ProcessPackageCacheOptions.max_bytes or uninstall pinned software"
148 )]
149 PackageCacheCapacity {
150 requested: u64,
151 current: u64,
152 limit: u64,
153 },
154
155 #[error(
156 "package cache entry limit exceeded: {current} of {limit} entries are in use; raise ProcessPackageCacheOptions.max_entries or uninstall pinned software"
157 )]
158 PackageCacheEntryCapacity { current: usize, limit: usize },
159
160 #[error(
163 "package cache pending acquisition limit {limit} reached; raise ProcessPackageCacheOptions.max_pending_acquisitions"
164 )]
165 PackageCachePendingLimit { limit: usize },
166
167 #[error("package cache is already configured differently: {0}")]
170 PackageCacheConfiguration(String),
171
172 #[error("software package not found: {0}")]
174 SoftwareNotFound(String),
175
176 #[error("transport error: {0}")]
178 Transport(#[from] ProtocolCodecError),
179
180 #[error("sidecar error: {0}")]
182 Sidecar(String),
183}
184
185impl From<TransportError> for ClientError {
186 fn from(error: TransportError) -> Self {
187 match error {
188 TransportError::Protocol(error) => ClientError::Transport(error),
189 TransportError::Sidecar(message) => ClientError::Sidecar(message),
190 }
191 }
192}
193
194impl ClientError {
195 pub(crate) fn from_rejection(
196 rejection: agentos_sidecar_client::wire::RejectedResponse,
197 ) -> Self {
198 if rejection.code == "ERR_AGENTOS_RESOURCE_LIMIT"
199 || rejection.code == "ERR_AGENTOS_OVERLOADED"
200 || rejection.code == "timeout"
201 || matches!(
202 rejection.operation.as_deref(),
203 Some("package.acquire" | "package.cache_stats")
204 )
205 {
206 let details = Box::new(ResourceLimitDetails {
207 limit_name: rejection.limit_name,
208 configured_limit: rejection.configured_limit,
209 current_usage: rejection.current_usage,
210 requested: rejection.requested,
211 unit: rejection.unit,
212 scope: rejection.scope,
213 vm_id: rejection.vm_id,
214 session_generation: rejection.session_generation,
215 capability_id: rejection.capability_id,
216 operation: rejection.operation,
217 configuration_path: rejection.configuration_path,
218 retryable: rejection.retryable,
219 errno: rejection.errno,
220 });
221 if rejection.code == "timeout" {
222 return Self::OperationTimedOut {
223 message: rejection.message,
224 details,
225 };
226 }
227 if rejection.code == "ERR_AGENTOS_RESOURCE_LIMIT"
228 || rejection.code == "ERR_AGENTOS_OVERLOADED"
229 {
230 return Self::ResourceLimit {
231 code: rejection.code,
232 message: rejection.message,
233 details,
234 };
235 }
236 return Self::PackageAcquisition {
237 code: rejection.code,
238 message: rejection.message,
239 details,
240 };
241 }
242 Self::Kernel {
243 code: rejection.code,
244 message: rejection.message,
245 }
246 }
247
248 pub fn batch_message(&self) -> String {
257 match self {
258 ClientError::Kernel { code, message } => {
259 if message.starts_with(&format!("{code}:")) {
260 message.clone()
261 } else {
262 format!("{code}: {message}")
263 }
264 }
265 ClientError::ResourceLimit { code, message, .. } => {
266 if message.starts_with(&format!("{code}:")) {
267 message.clone()
268 } else {
269 format!("{code}: {message}")
270 }
271 }
272 ClientError::PathNotAbsolute(_)
273 | ClientError::PathNotNormalized(_)
274 | ClientError::PathReadOnly(_)
275 | ClientError::InvalidConfig(_)
276 | ClientError::ProcessNotFound(_)
277 | ClientError::ExecutionTimedOut { .. }
278 | ClientError::OperationTimedOut { .. }
279 | ClientError::TerminationFailed { .. }
280 | ClientError::ShellNotFound(_)
281 | ClientError::InvalidSchedule(_)
282 | ClientError::PastSchedule(_)
283 | ClientError::InvalidPackageSource(_)
284 | ClientError::PackageDownload(_)
285 | ClientError::PackageTooLarge { .. }
286 | ClientError::PackageDigestMismatch { .. }
287 | ClientError::InvalidPackageFormat(_)
288 | ClientError::PackageIo(_)
289 | ClientError::PackageAcquisition { .. }
290 | ClientError::PackageCacheCapacity { .. }
291 | ClientError::PackageCacheEntryCapacity { .. }
292 | ClientError::PackageCachePendingLimit { .. }
293 | ClientError::PackageCacheConfiguration(_)
294 | ClientError::SoftwareNotFound(_)
295 | ClientError::Transport(_)
296 | ClientError::Sidecar(_) => self.to_string(),
297 }
298 }
299}
300
301pub type ClientResult<T> = std::result::Result<T, ClientError>;
303
304#[cfg(test)]
305mod tests {
306 use super::*;
307
308 #[test]
309 fn structured_resource_limit_metadata_survives_rejection_conversion() {
310 let error = ClientError::from_rejection(agentos_sidecar_client::wire::RejectedResponse {
311 code: String::from("ERR_AGENTOS_RESOURCE_LIMIT"),
312 message: String::from("handle command bytes exceeded"),
313 limit_name: Some(String::from("handleCommandBytes")),
314 configured_limit: Some(4096),
315 current_usage: Some(3072),
316 requested: Some(2048),
317 unit: Some(String::from("bytes")),
318 scope: Some(String::from("vm")),
319 vm_id: Some(String::from("vm-1")),
320 session_generation: Some(3),
321 capability_id: Some(11),
322 operation: Some(String::from("socket.write")),
323 configuration_path: Some(String::from("limits.reactor.maxHandleCommandBytes")),
324 retryable: Some(true),
325 errno: Some(String::from("EAGAIN")),
326 });
327
328 match error {
329 ClientError::ResourceLimit { details, .. } => {
330 assert_eq!(details.configured_limit, Some(4096));
331 assert_eq!(details.current_usage, Some(3072));
332 assert_eq!(details.requested, Some(2048));
333 assert_eq!(
334 details.configuration_path.as_deref(),
335 Some("limits.reactor.maxHandleCommandBytes")
336 );
337 assert_eq!(details.retryable, Some(true));
338 assert_eq!(details.errno.as_deref(), Some("EAGAIN"));
339 }
340 other => panic!("expected resource limit, got {other:?}"),
341 }
342 }
343
344 #[test]
345 fn sidecar_package_rejection_is_not_misclassified_as_a_kernel_error() {
346 for operation in ["package.acquire", "package.cache_stats"] {
347 for code in [
348 "invalid_package_source",
349 "invalid_package_format",
350 "package_digest_mismatch",
351 "package_download_failed",
352 "package_io_failed",
353 "package_cache_configuration",
354 "package_acquisition_failed",
355 ] {
356 let error =
357 ClientError::from_rejection(agentos_sidecar_client::wire::RejectedResponse {
358 code: code.into(),
359 message: "specific package failure".into(),
360 operation: Some(operation.into()),
361 limit_name: None,
362 configured_limit: None,
363 current_usage: None,
364 requested: None,
365 unit: None,
366 scope: Some("session".into()),
367 vm_id: None,
368 session_generation: None,
369 capability_id: None,
370 configuration_path: None,
371 retryable: Some(false),
372 errno: Some("EIO".into()),
373 });
374 let ClientError::PackageAcquisition {
375 code: actual,
376 message,
377 details,
378 } = error
379 else {
380 panic!("expected a typed package acquisition error");
381 };
382 assert_eq!(actual, code);
383 assert_eq!(message, "specific package failure");
384 assert_eq!(details.operation.as_deref(), Some(operation));
385 assert_eq!(details.scope.as_deref(), Some("session"));
386 assert_eq!(details.retryable, Some(false));
387 assert_eq!(details.errno.as_deref(), Some("EIO"));
388 }
389 }
390 }
391
392 #[test]
393 fn package_rejections_keep_limit_timeout_precedence_and_non_package_errors_unchanged() {
394 for code in [
395 "ERR_AGENTOS_RESOURCE_LIMIT",
396 "ERR_AGENTOS_OVERLOADED",
397 "timeout",
398 "invalid_package_source",
399 ] {
400 for operation in ["package.acquire", "filesystem.read"] {
401 let error =
402 ClientError::from_rejection(agentos_sidecar_client::wire::RejectedResponse {
403 code: code.into(),
404 message: "wire failure".into(),
405 limit_name: Some("exampleLimit".into()),
406 configured_limit: Some(10),
407 current_usage: Some(8),
408 requested: Some(4),
409 unit: Some("bytes".into()),
410 scope: Some("session".into()),
411 vm_id: None,
412 session_generation: None,
413 capability_id: None,
414 operation: Some(operation.into()),
415 configuration_path: Some("example.limit".into()),
416 retryable: Some(true),
417 errno: Some("EAGAIN".into()),
418 });
419 let details = match (code, operation, error) {
420 ("timeout", _, ClientError::OperationTimedOut { details, .. }) => details,
421 (
422 "ERR_AGENTOS_RESOURCE_LIMIT" | "ERR_AGENTOS_OVERLOADED",
423 _,
424 ClientError::ResourceLimit { details, .. },
425 ) => details,
426 (
427 "invalid_package_source",
428 "package.acquire",
429 ClientError::PackageAcquisition { details, .. },
430 ) => details,
431 (
432 "invalid_package_source",
433 "filesystem.read",
434 ClientError::Kernel { code, message },
435 ) => {
436 assert_eq!(code, "invalid_package_source");
437 assert_eq!(message, "wire failure");
438 continue;
439 }
440 (_, _, other) => panic!("unexpected rejection classification: {other:?}"),
441 };
442 assert_eq!(details.limit_name.as_deref(), Some("exampleLimit"));
443 assert_eq!(details.configured_limit, Some(10));
444 assert_eq!(details.current_usage, Some(8));
445 assert_eq!(details.requested, Some(4));
446 assert_eq!(details.configuration_path.as_deref(), Some("example.limit"));
447 assert_eq!(details.retryable, Some(true));
448 assert_eq!(details.errno.as_deref(), Some("EAGAIN"));
449 }
450 }
451 }
452}