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 #[error("sidecar exited: {0}")]
187 SidecarExited(String),
188}
189
190impl From<TransportError> for ClientError {
191 fn from(error: TransportError) -> Self {
192 match error {
193 TransportError::Protocol(error) => ClientError::Transport(error),
194 TransportError::Sidecar(message) => ClientError::Sidecar(message),
195 TransportError::Disconnected(message) => ClientError::SidecarExited(message),
196 }
197 }
198}
199
200impl ClientError {
201 pub(crate) fn from_rejection(
202 rejection: agentos_sidecar_client::wire::RejectedResponse,
203 ) -> Self {
204 if rejection.code == "ERR_AGENTOS_RESOURCE_LIMIT"
205 || rejection.code == "ERR_AGENTOS_OVERLOADED"
206 || rejection.code == "timeout"
207 || matches!(
208 rejection.operation.as_deref(),
209 Some("package.acquire" | "package.cache_stats")
210 )
211 {
212 let details = Box::new(ResourceLimitDetails {
213 limit_name: rejection.limit_name,
214 configured_limit: rejection.configured_limit,
215 current_usage: rejection.current_usage,
216 requested: rejection.requested,
217 unit: rejection.unit,
218 scope: rejection.scope,
219 vm_id: rejection.vm_id,
220 session_generation: rejection.session_generation,
221 capability_id: rejection.capability_id,
222 operation: rejection.operation,
223 configuration_path: rejection.configuration_path,
224 retryable: rejection.retryable,
225 errno: rejection.errno,
226 });
227 if rejection.code == "timeout" {
228 return Self::OperationTimedOut {
229 message: rejection.message,
230 details,
231 };
232 }
233 if rejection.code == "ERR_AGENTOS_RESOURCE_LIMIT"
234 || rejection.code == "ERR_AGENTOS_OVERLOADED"
235 {
236 return Self::ResourceLimit {
237 code: rejection.code,
238 message: rejection.message,
239 details,
240 };
241 }
242 return Self::PackageAcquisition {
243 code: rejection.code,
244 message: rejection.message,
245 details,
246 };
247 }
248 Self::Kernel {
249 code: rejection.code,
250 message: rejection.message,
251 }
252 }
253
254 pub fn batch_message(&self) -> String {
263 match self {
264 ClientError::Kernel { code, message } => {
265 if message.starts_with(&format!("{code}:")) {
266 message.clone()
267 } else {
268 format!("{code}: {message}")
269 }
270 }
271 ClientError::ResourceLimit { code, message, .. } => {
272 if message.starts_with(&format!("{code}:")) {
273 message.clone()
274 } else {
275 format!("{code}: {message}")
276 }
277 }
278 ClientError::PathNotAbsolute(_)
279 | ClientError::PathNotNormalized(_)
280 | ClientError::PathReadOnly(_)
281 | ClientError::InvalidConfig(_)
282 | ClientError::ProcessNotFound(_)
283 | ClientError::ExecutionTimedOut { .. }
284 | ClientError::OperationTimedOut { .. }
285 | ClientError::TerminationFailed { .. }
286 | ClientError::ShellNotFound(_)
287 | ClientError::InvalidSchedule(_)
288 | ClientError::PastSchedule(_)
289 | ClientError::InvalidPackageSource(_)
290 | ClientError::PackageDownload(_)
291 | ClientError::PackageTooLarge { .. }
292 | ClientError::PackageDigestMismatch { .. }
293 | ClientError::InvalidPackageFormat(_)
294 | ClientError::PackageIo(_)
295 | ClientError::PackageAcquisition { .. }
296 | ClientError::PackageCacheCapacity { .. }
297 | ClientError::PackageCacheEntryCapacity { .. }
298 | ClientError::PackageCachePendingLimit { .. }
299 | ClientError::PackageCacheConfiguration(_)
300 | ClientError::SoftwareNotFound(_)
301 | ClientError::Transport(_)
302 | ClientError::Sidecar(_)
303 | ClientError::SidecarExited(_) => self.to_string(),
304 }
305 }
306}
307
308pub type ClientResult<T> = std::result::Result<T, ClientError>;
310
311#[cfg(test)]
312mod tests {
313 use super::*;
314
315 #[test]
316 fn structured_resource_limit_metadata_survives_rejection_conversion() {
317 let error = ClientError::from_rejection(agentos_sidecar_client::wire::RejectedResponse {
318 code: String::from("ERR_AGENTOS_RESOURCE_LIMIT"),
319 message: String::from("handle command bytes exceeded"),
320 limit_name: Some(String::from("handleCommandBytes")),
321 configured_limit: Some(4096),
322 current_usage: Some(3072),
323 requested: Some(2048),
324 unit: Some(String::from("bytes")),
325 scope: Some(String::from("vm")),
326 vm_id: Some(String::from("vm-1")),
327 session_generation: Some(3),
328 capability_id: Some(11),
329 operation: Some(String::from("socket.write")),
330 configuration_path: Some(String::from("limits.reactor.maxHandleCommandBytes")),
331 retryable: Some(true),
332 errno: Some(String::from("EAGAIN")),
333 });
334
335 match error {
336 ClientError::ResourceLimit { details, .. } => {
337 assert_eq!(details.configured_limit, Some(4096));
338 assert_eq!(details.current_usage, Some(3072));
339 assert_eq!(details.requested, Some(2048));
340 assert_eq!(
341 details.configuration_path.as_deref(),
342 Some("limits.reactor.maxHandleCommandBytes")
343 );
344 assert_eq!(details.retryable, Some(true));
345 assert_eq!(details.errno.as_deref(), Some("EAGAIN"));
346 }
347 other => panic!("expected resource limit, got {other:?}"),
348 }
349 }
350
351 #[test]
352 fn sidecar_package_rejection_is_not_misclassified_as_a_kernel_error() {
353 for operation in ["package.acquire", "package.cache_stats"] {
354 for code in [
355 "invalid_package_source",
356 "invalid_package_format",
357 "package_digest_mismatch",
358 "package_download_failed",
359 "package_io_failed",
360 "package_cache_configuration",
361 "package_acquisition_failed",
362 ] {
363 let error =
364 ClientError::from_rejection(agentos_sidecar_client::wire::RejectedResponse {
365 code: code.into(),
366 message: "specific package failure".into(),
367 operation: Some(operation.into()),
368 limit_name: None,
369 configured_limit: None,
370 current_usage: None,
371 requested: None,
372 unit: None,
373 scope: Some("session".into()),
374 vm_id: None,
375 session_generation: None,
376 capability_id: None,
377 configuration_path: None,
378 retryable: Some(false),
379 errno: Some("EIO".into()),
380 });
381 let ClientError::PackageAcquisition {
382 code: actual,
383 message,
384 details,
385 } = error
386 else {
387 panic!("expected a typed package acquisition error");
388 };
389 assert_eq!(actual, code);
390 assert_eq!(message, "specific package failure");
391 assert_eq!(details.operation.as_deref(), Some(operation));
392 assert_eq!(details.scope.as_deref(), Some("session"));
393 assert_eq!(details.retryable, Some(false));
394 assert_eq!(details.errno.as_deref(), Some("EIO"));
395 }
396 }
397 }
398
399 #[test]
400 fn package_rejections_keep_limit_timeout_precedence_and_non_package_errors_unchanged() {
401 for code in [
402 "ERR_AGENTOS_RESOURCE_LIMIT",
403 "ERR_AGENTOS_OVERLOADED",
404 "timeout",
405 "invalid_package_source",
406 ] {
407 for operation in ["package.acquire", "filesystem.read"] {
408 let error =
409 ClientError::from_rejection(agentos_sidecar_client::wire::RejectedResponse {
410 code: code.into(),
411 message: "wire failure".into(),
412 limit_name: Some("exampleLimit".into()),
413 configured_limit: Some(10),
414 current_usage: Some(8),
415 requested: Some(4),
416 unit: Some("bytes".into()),
417 scope: Some("session".into()),
418 vm_id: None,
419 session_generation: None,
420 capability_id: None,
421 operation: Some(operation.into()),
422 configuration_path: Some("example.limit".into()),
423 retryable: Some(true),
424 errno: Some("EAGAIN".into()),
425 });
426 let details = match (code, operation, error) {
427 ("timeout", _, ClientError::OperationTimedOut { details, .. }) => details,
428 (
429 "ERR_AGENTOS_RESOURCE_LIMIT" | "ERR_AGENTOS_OVERLOADED",
430 _,
431 ClientError::ResourceLimit { details, .. },
432 ) => details,
433 (
434 "invalid_package_source",
435 "package.acquire",
436 ClientError::PackageAcquisition { details, .. },
437 ) => details,
438 (
439 "invalid_package_source",
440 "filesystem.read",
441 ClientError::Kernel { code, message },
442 ) => {
443 assert_eq!(code, "invalid_package_source");
444 assert_eq!(message, "wire failure");
445 continue;
446 }
447 (_, _, other) => panic!("unexpected rejection classification: {other:?}"),
448 };
449 assert_eq!(details.limit_name.as_deref(), Some("exampleLimit"));
450 assert_eq!(details.configured_limit, Some(10));
451 assert_eq!(details.current_usage, Some(8));
452 assert_eq!(details.requested, Some(4));
453 assert_eq!(details.configuration_path.as_deref(), Some("example.limit"));
454 assert_eq!(details.retryable, Some(true));
455 assert_eq!(details.errno.as_deref(), Some("EAGAIN"));
456 }
457 }
458 }
459}