use futures::executor::block_on;use mockito::Server; use super::*;use crate::server::retry_strategies::is_transient_http_error;use crate::workspaces::user_workspaces::{TeamContextForOperation, TeamlessScopeForTest}; /// Sends a GET request to a mock endpoint returning `status`/`headers`/`body`, then feeds the/// resulting response through [`ServerApi::error_from_response`].fn error_from_mock_response(status: usize, headers: &[(&str, &str)], body: &str) -> anyhow::Error { let mut server = Server::new(); let mut mock = server .mock("GET", "/error") .with_status(status) .with_body(body); for (name, value) in headers { mock = mock.with_header(*name, value); } mock.create(); let url = format!("{}/error", server.url()); block_on(async move { let response = http_client::Client::new_for_test() .get(url) .send() .await .unwrap(); ServerApi::error_from_response(response).await })} /// The status carried by the [`HttpStatusError`] in `err`'s chain, if any.fn status_in_chain(err: &anyhow::Error) -> Option<u16> { err.chain() .find_map(|cause| cause.downcast_ref::<HttpStatusError>()) .map(|status_error| status_error.status)} #[test]fn permanent_4xx_client_error_carries_status_and_fails_fast() { let err = error_from_mock_response( 403, &[], r#"{"error": "checkpoint generation is incomplete"}"#, ); assert_eq!(status_in_chain(&err), Some(403)); assert!(!is_transient_http_error(&err)); assert_eq!(err.to_string(), "checkpoint generation is incomplete"); assert_eq!( err.downcast_ref::<ClientError>().unwrap().error, "checkpoint generation is incomplete" );} #[test]fn permanent_4xx_without_parseable_body_still_carries_status() { let err = error_from_mock_response(404, &[], "not found"); assert_eq!(status_in_chain(&err), Some(404)); assert!(!is_transient_http_error(&err)); assert_eq!( err.to_string(), "API request failed with status 404 Not Found" );} #[test]fn transient_5xx_still_retries() { let err = error_from_mock_response(503, &[], "unavailable"); assert_eq!(status_in_chain(&err), Some(503)); assert!(is_transient_http_error(&err));} #[test]fn at_capacity_header_wraps_capacity_error_and_still_carries_status() { let err = error_from_mock_response( 403, &[(WARP_ERROR_CODE_HEADER, WARP_ERROR_CODE_AT_CAPACITY)], r#"{"error": "at capacity", "running_agents": 5}"#, ); assert_eq!(status_in_chain(&err), Some(403)); assert!(!is_transient_http_error(&err)); assert_eq!( err.downcast_ref::<CloudAgentCapacityError>() .unwrap() .running_agents, 5 );} #[test]fn out_of_credits_429_wraps_quota_limit_and_stays_transient() { let err = error_from_mock_response( 429, &[(WARP_ERROR_CODE_HEADER, WARP_ERROR_CODE_OUT_OF_CREDITS)], r#"{"userDisplayMessage": "You're out of credits"}"#, ); // 429 always retries regardless of error code, matching every other public-API caller. assert_eq!(status_in_chain(&err), Some(429)); assert!(is_transient_http_error(&err)); assert!(matches!( err.downcast_ref::<AIApiError>().unwrap(), AIApiError::QuotaLimit { user_display_message: Some(message) } if message == "You're out of credits" ));} #[test]fn team_uid_header_value_includes_only_resolved_team_scope() { let team_uid = 7.into(); let team_scope = RequestTeamScope::from_scope(&TeamContextForOperation::new_for_test(team_uid)); assert_eq!( ServerApi::team_uid_header_value(team_scope), Some(team_uid.uid().to_string()) ); assert_eq!( ServerApi::team_uid_header_value(RequestTeamScope::from_scope(&TeamlessScopeForTest)), None );}
use futures::executor::block_on;
use mockito::Server;
use super::*;
use crate::server::retry_strategies::is_transient_http_error;
use crate::workspaces::user_workspaces::{TeamContextForOperation, TeamlessScopeForTest};
/// Sends a GET request to a mock endpoint returning `status`/`headers`/`body`, then feeds the
/// resulting response through [`ServerApi::error_from_response`].
fn error_from_mock_response(status: usize, headers: &[(&str, &str)], body: &str) -> anyhow::Error {
let mut server = Server::new();
let mut mock = server
.mock("GET", "/error")
.with_status(status)
.with_body(body);
for (name, value) in headers {
mock = mock.with_header(*name, value);
}
mock.create();
let url = format!("{}/error", server.url());
block_on(async move {
let response = http_client::Client::new_for_test()
.get(url)
.send()
.await
.unwrap();
ServerApi::error_from_response(response).await
})
}
/// The status carried by the [`HttpStatusError`] in `err`'s chain, if any.
fn status_in_chain(err: &anyhow::Error) -> Option<u16> {
err.chain()
.find_map(|cause| cause.downcast_ref::<HttpStatusError>())
.map(|status_error| status_error.status)
}
#[test]
fn permanent_4xx_client_error_carries_status_and_fails_fast() {
let err = error_from_mock_response(
403,
&[],
r#"{"error": "checkpoint generation is incomplete"}"#,
);
assert_eq!(status_in_chain(&err), Some(403));
assert!(!is_transient_http_error(&err));
assert_eq!(err.to_string(), "checkpoint generation is incomplete");
assert_eq!(
err.downcast_ref::<ClientError>().unwrap().error,
"checkpoint generation is incomplete"
);
}
#[test]
fn permanent_4xx_without_parseable_body_still_carries_status() {
let err = error_from_mock_response(404, &[], "not found");
assert_eq!(status_in_chain(&err), Some(404));
assert!(!is_transient_http_error(&err));
assert_eq!(
err.to_string(),
"API request failed with status 404 Not Found"
);
}
#[test]
fn transient_5xx_still_retries() {
let err = error_from_mock_response(503, &[], "unavailable");
assert_eq!(status_in_chain(&err), Some(503));
assert!(is_transient_http_error(&err));
}
#[test]
fn at_capacity_header_wraps_capacity_error_and_still_carries_status() {
let err = error_from_mock_response(
403,
&[(WARP_ERROR_CODE_HEADER, WARP_ERROR_CODE_AT_CAPACITY)],
r#"{"error": "at capacity", "running_agents": 5}"#,
);
assert_eq!(status_in_chain(&err), Some(403));
assert!(!is_transient_http_error(&err));
assert_eq!(
err.downcast_ref::<CloudAgentCapacityError>()
.unwrap()
.running_agents,
5
);
}
#[test]
fn out_of_credits_429_wraps_quota_limit_and_stays_transient() {
let err = error_from_mock_response(
429,
&[(WARP_ERROR_CODE_HEADER, WARP_ERROR_CODE_OUT_OF_CREDITS)],
r#"{"userDisplayMessage": "You're out of credits"}"#,
);
// 429 always retries regardless of error code, matching every other public-API caller.
assert_eq!(status_in_chain(&err), Some(429));
assert!(is_transient_http_error(&err));
assert!(matches!(
err.downcast_ref::<AIApiError>().unwrap(),
AIApiError::QuotaLimit {
user_display_message: Some(message)
} if message == "You're out of credits"
));
}
#[test]
fn team_uid_header_value_includes_only_resolved_team_scope() {
let team_uid = 7.into();
let team_scope = RequestTeamScope::from_scope(&TeamContextForOperation::new_for_test(team_uid));
assert_eq!(
ServerApi::team_uid_header_value(team_scope),
Some(team_uid.uid().to_string())
);
assert_eq!(
ServerApi::team_uid_header_value(RequestTeamScope::from_scope(&TeamlessScopeForTest)),
None
);
}