diff --git a/crates/api_clients/seedance2pro_client/src/error/mod.rs b/crates/api_clients/seedance2pro_client/src/error/mod.rs index 95d4dcf3c8..9ae37a8074 100644 --- a/crates/api_clients/seedance2pro_client/src/error/mod.rs +++ b/crates/api_clients/seedance2pro_client/src/error/mod.rs @@ -1,3 +1,4 @@ +pub mod seedance2pro_bad_request_api_error; pub mod seedance2pro_client_error; pub mod seedance2pro_error; pub mod seedance2pro_generic_api_error; diff --git a/crates/api_clients/seedance2pro_client/src/error/seedance2pro_bad_request_api_error.rs b/crates/api_clients/seedance2pro_client/src/error/seedance2pro_bad_request_api_error.rs new file mode 100644 index 0000000000..eb816ef90d --- /dev/null +++ b/crates/api_clients/seedance2pro_client/src/error/seedance2pro_bad_request_api_error.rs @@ -0,0 +1,29 @@ +use std::error::Error; +use std::fmt::{Display, Formatter}; + +/// These represent known "400s" that are the fault of the client sending a bad request. +#[derive(Debug)] +pub enum Seedance2ProBadRequestApiError { + /// The video generation request was flagged as a content violation. + VideoGenerationViolation { raw_body: String }, + + /// The prompt exceeded Kinovi's maximum length (10,000 characters at the + /// time of writing). + PromptIsTooLong { raw_body: String }, + + /// Too many media URLs were attached to the request (`uploadedUrls` is + /// capped at 9 elements at the time of writing). + TooManyUrls { raw_body: String }, +} + +impl Error for Seedance2ProBadRequestApiError {} + +impl Display for Seedance2ProBadRequestApiError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::VideoGenerationViolation { raw_body } => write!(f, "Video generation violation: {}", raw_body), + Self::PromptIsTooLong { raw_body } => write!(f, "Prompt is too long: {}", raw_body), + Self::TooManyUrls { raw_body } => write!(f, "Too many urls: {}", raw_body), + } + } +} diff --git a/crates/api_clients/seedance2pro_client/src/error/seedance2pro_error.rs b/crates/api_clients/seedance2pro_client/src/error/seedance2pro_error.rs index 51652ade72..77565579b2 100644 --- a/crates/api_clients/seedance2pro_client/src/error/seedance2pro_error.rs +++ b/crates/api_clients/seedance2pro_client/src/error/seedance2pro_error.rs @@ -1,3 +1,4 @@ +use crate::error::seedance2pro_bad_request_api_error::Seedance2ProBadRequestApiError; use crate::error::seedance2pro_client_error::Seedance2ProClientError; use crate::error::seedance2pro_generic_api_error::Seedance2ProGenericApiError; use crate::error::seedance2pro_specific_api_error::Seedance2ProSpecificApiError; @@ -9,6 +10,7 @@ pub enum Seedance2ProError { Client(Seedance2ProClientError), ApiSpecific(Seedance2ProSpecificApiError), ApiGeneric(Seedance2ProGenericApiError), + ApiBadRequest(Seedance2ProBadRequestApiError), } impl Seedance2ProError { @@ -38,6 +40,7 @@ impl std::fmt::Display for Seedance2ProError { Self::Client(e) => write!(f, "Seedance2ProClientError: {:?}", e), Self::ApiSpecific(e) => write!(f, "Seedance2ProSpecificApiError: {:?}", e), Self::ApiGeneric(e) => write!(f, "Seedance2ProGenericApiError: {:?}", e), + Self::ApiBadRequest(e) => write!(f, "Seedance2ProBadRequestApiError: {:?}", e), } } } @@ -59,3 +62,9 @@ impl From for Seedance2ProError { Self::ApiGeneric(error) } } + +impl From for Seedance2ProError { + fn from(error: Seedance2ProBadRequestApiError) -> Self { + Self::ApiBadRequest(error) + } +} diff --git a/crates/api_clients/seedance2pro_client/src/error/seedance2pro_specific_api_error.rs b/crates/api_clients/seedance2pro_client/src/error/seedance2pro_specific_api_error.rs index 4ac0f56abe..c3c38ce746 100644 --- a/crates/api_clients/seedance2pro_client/src/error/seedance2pro_specific_api_error.rs +++ b/crates/api_clients/seedance2pro_client/src/error/seedance2pro_specific_api_error.rs @@ -8,9 +8,6 @@ pub enum Seedance2ProSpecificApiError { /// The session cookies are expired or invalid. UnauthorizedSessionExpired, - /// The video generation request was flagged as a content violation. - VideoGenerationViolation(String), - /// The account does not have enough credits to perform the operation. BillingError { status_code: StatusCode, @@ -24,7 +21,6 @@ impl Display for Seedance2ProSpecificApiError { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { Self::UnauthorizedSessionExpired => write!(f, "Unauthorized: session cookies expired or invalid."), - Self::VideoGenerationViolation(body) => write!(f, "Video generation violation: {}", body), Self::BillingError { status_code, body } => write!(f, "Billing error (status {}): {}", status_code, body), } } diff --git a/crates/api_clients/seedance2pro_client/src/requests/generate_image/generate_image.rs b/crates/api_clients/seedance2pro_client/src/requests/generate_image/generate_image.rs index f89cbe8810..f45abb38fe 100644 --- a/crates/api_clients/seedance2pro_client/src/requests/generate_image/generate_image.rs +++ b/crates/api_clients/seedance2pro_client/src/requests/generate_image/generate_image.rs @@ -1,8 +1,8 @@ use crate::creds::seedance2pro_session::Seedance2ProSession; +use crate::error::seedance2pro_bad_request_api_error::Seedance2ProBadRequestApiError; use crate::error::seedance2pro_client_error::Seedance2ProClientError; use crate::error::seedance2pro_error::Seedance2ProError; use crate::error::seedance2pro_generic_api_error::Seedance2ProGenericApiError; -use crate::error::seedance2pro_specific_api_error::Seedance2ProSpecificApiError; use crate::requests::generate_image::request_types::*; use crate::requests::kinovi_host::{KinoviHost, resolve_host}; use crate::utils::categorize_seedance2pro_error::categorize_seedance2pro_error; @@ -293,7 +293,7 @@ pub async fn generate_image(args: GenerateImageArgs<'_>) -> Result) -> Result) -> Result bool { BILLING_ERROR_MARKERS.iter().any(|marker| body_lower.contains(marker)) } +/// Check if the response body is a zod validation failure for an over-long prompt. +fn is_prompt_too_long_error(body: &str) -> bool { + body.contains(STRING_TOO_LONG_MARKER) && body.contains(PROMPT_FIELD_MARKER) +} + +/// Check if the response body is a zod validation failure for too many uploaded urls. +fn is_too_many_urls_error(body: &str) -> bool { + body.contains(ARRAY_TOO_LARGE_MARKER) && body.contains(UPLOADED_URLS_FIELD_MARKER) +} + #[cfg(test)] mod tests { use super::*; use crate::error::seedance2pro_error::Seedance2ProError; - #[test] - fn real_not_enough_credits_response() { - let body = std::fs::read_to_string("test_data/responses/not_enough_credits.json") - .expect("Failed to read test data file"); + mod billing_tests { + use super::*; - let error = categorize_seedance2pro_error(StatusCode::BAD_REQUEST, body); + #[test] + fn real_not_enough_credits_response() { + let body = read_response_body("not_enough_credits.json"); - match error { - Seedance2ProError::ApiSpecific( - Seedance2ProSpecificApiError::BillingError { status_code, body } - ) => { - assert_eq!(status_code, StatusCode::BAD_REQUEST); - assert!(body.contains("credits not enough")); + let error = categorize_seedance2pro_error(StatusCode::BAD_REQUEST, body); + + match error { + Seedance2ProError::ApiSpecific( + Seedance2ProSpecificApiError::BillingError { status_code, body } + ) => { + assert_eq!(status_code, StatusCode::BAD_REQUEST); + assert!(body.contains("credits not enough")); + } + other => panic!("Expected BillingError, got: {:?}", other), } - other => panic!("Expected BillingError, got: {:?}", other), } - } - #[test] - fn billing_error_case_insensitive() { - let body = r#"{"error":"Credits Not Enough"}"#.to_string(); + #[test] + fn billing_error_case_insensitive() { + let body = r#"{"error":"Credits Not Enough"}"#.to_string(); - let error = categorize_seedance2pro_error(StatusCode::BAD_REQUEST, body); + let error = categorize_seedance2pro_error(StatusCode::BAD_REQUEST, body); - match error { - Seedance2ProError::ApiSpecific( - Seedance2ProSpecificApiError::BillingError { .. } - ) => {} - other => panic!("Expected BillingError, got: {:?}", other), - } - } - - #[test] - fn unknown_error_falls_back_to_uncategorized() { - let body = r#"{"error":"something else went wrong"}"#.to_string(); - - let error = categorize_seedance2pro_error( - StatusCode::INTERNAL_SERVER_ERROR, - body.clone(), - ); - - match error { - Seedance2ProError::ApiGeneric( - Seedance2ProGenericApiError::UncategorizedBadResponseWithStatusAndBody { status_code, body: b } - ) => { - assert_eq!(status_code, StatusCode::INTERNAL_SERVER_ERROR); - assert_eq!(b, body); + match error { + Seedance2ProError::ApiSpecific( + Seedance2ProSpecificApiError::BillingError { .. } + ) => {} + other => panic!("Expected BillingError, got: {:?}", other), } - other => panic!("Expected UncategorizedBadResponseWithStatusAndBody, got: {:?}", other), } } - #[test] - fn empty_body_falls_back_to_uncategorized() { - let error = categorize_seedance2pro_error( - StatusCode::BAD_REQUEST, - String::new(), - ); + mod bad_request_tests { + use super::*; - match error { - Seedance2ProError::ApiGeneric( - Seedance2ProGenericApiError::UncategorizedBadResponseWithStatusAndBody { .. } - ) => {} - other => panic!("Expected UncategorizedBadResponseWithStatusAndBody, got: {:?}", other), + #[test] + fn real_prompt_too_long_response() { + let body = read_response_body("prompt_too_long.json"); + + let error = categorize_seedance2pro_error(StatusCode::BAD_REQUEST, body); + + match error { + Seedance2ProError::ApiBadRequest( + Seedance2ProBadRequestApiError::PromptIsTooLong { raw_body } + ) => { + assert!(raw_body.contains("String must contain at most 10000 character(s)")); + assert!(raw_body.contains("\"prompt\"")); + } + other => panic!("Expected PromptIsTooLong, got: {:?}", other), + } } + + #[test] + fn real_too_many_urls_response() { + let body = read_response_body("too_many_urls.json"); + + let error = categorize_seedance2pro_error(StatusCode::BAD_REQUEST, body); + + match error { + Seedance2ProError::ApiBadRequest( + Seedance2ProBadRequestApiError::TooManyUrls { raw_body } + ) => { + assert!(raw_body.contains("Array must contain at most 9 element(s)")); + assert!(raw_body.contains("\"uploadedUrls\"")); + } + other => panic!("Expected TooManyUrls, got: {:?}", other), + } + } + + #[test] + fn bad_request_markers_require_400_status() { + let body = read_response_body("prompt_too_long.json"); + + let error = categorize_seedance2pro_error(StatusCode::INTERNAL_SERVER_ERROR, body); + + match error { + Seedance2ProError::ApiGeneric( + Seedance2ProGenericApiError::UncategorizedBadResponseWithStatusAndBody { .. } + ) => {} + other => panic!("Expected UncategorizedBadResponseWithStatusAndBody, got: {:?}", other), + } + } + + #[test] + fn validation_failure_for_other_field_is_not_categorized() { + // Same zod shape, but for a field we don't have a variant for. + let body = r#"[{"error":{"json":{"message":"validation failed - String must contain at most 100 character(s)","code":-32600,"data":{"code":"BAD_REQUEST","httpStatus":400,"zodError":{"formErrors":[],"fieldErrors":{"apiParams":[{"code":"custom","message":"String must contain at most 100 character(s)","path":["apiParams","negativePrompt"],"fatal":true}]}}}}}]"#.to_string(); + + let error = categorize_seedance2pro_error(StatusCode::BAD_REQUEST, body); + + match error { + Seedance2ProError::ApiGeneric( + Seedance2ProGenericApiError::UncategorizedBadResponseWithStatusAndBody { .. } + ) => {} + other => panic!("Expected UncategorizedBadResponseWithStatusAndBody, got: {:?}", other), + } + } + } + + mod fallback_tests { + use super::*; + + #[test] + fn unknown_error_falls_back_to_uncategorized() { + let body = r#"{"error":"something else went wrong"}"#.to_string(); + + let error = categorize_seedance2pro_error( + StatusCode::INTERNAL_SERVER_ERROR, + body.clone(), + ); + + match error { + Seedance2ProError::ApiGeneric( + Seedance2ProGenericApiError::UncategorizedBadResponseWithStatusAndBody { status_code, body: b } + ) => { + assert_eq!(status_code, StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(b, body); + } + other => panic!("Expected UncategorizedBadResponseWithStatusAndBody, got: {:?}", other), + } + } + + #[test] + fn empty_body_falls_back_to_uncategorized() { + let error = categorize_seedance2pro_error( + StatusCode::BAD_REQUEST, + String::new(), + ); + + match error { + Seedance2ProError::ApiGeneric( + Seedance2ProGenericApiError::UncategorizedBadResponseWithStatusAndBody { .. } + ) => {} + other => panic!("Expected UncategorizedBadResponseWithStatusAndBody, got: {:?}", other), + } + } + } + + fn read_response_body(filename: &str) -> String { + std::fs::read_to_string(format!("test_data/responses/{}", filename)) + .expect("Failed to read test data file") } } diff --git a/crates/api_clients/seedance2pro_client/test_data/responses/prompt_too_long.json b/crates/api_clients/seedance2pro_client/test_data/responses/prompt_too_long.json new file mode 100644 index 0000000000..43e9a5f1cf --- /dev/null +++ b/crates/api_clients/seedance2pro_client/test_data/responses/prompt_too_long.json @@ -0,0 +1 @@ +[{"error":{"json":{"message":"[\n {\n \"code\": \"custom\",\n \"message\": \"参数验证失败 - String must contain at most 10000 character(s)\",\n \"path\": [\n \"apiParams\",\n \"prompt\"\n ],\n \"fatal\": true\n }\n]","code":-32600,"data":{"code":"BAD_REQUEST","httpStatus":400,"path":"workflow.runTask","zodError":{"formErrors":[],"fieldErrors":{"apiParams":[{"code":"custom","message":"参数验证失败 - String must contain at most 10000 character(s)","path":["apiParams","prompt"],"fatal":true}]}}},"stack":null},"meta":{"values":{"stack":["undefined"]},"v":1}}}] diff --git a/crates/api_clients/seedance2pro_client/test_data/responses/too_many_urls.json b/crates/api_clients/seedance2pro_client/test_data/responses/too_many_urls.json new file mode 100644 index 0000000000..6476a256be --- /dev/null +++ b/crates/api_clients/seedance2pro_client/test_data/responses/too_many_urls.json @@ -0,0 +1 @@ +[{"error":{"json":{"message":"[\n {\n \"code\": \"custom\",\n \"message\": \"参数验证失败 - Array must contain at most 9 element(s)\",\n \"path\": [\n \"apiParams\",\n \"uploadedUrls\"\n ],\n \"fatal\": true\n }\n]","code":-32600,"data":{"code":"BAD_REQUEST","httpStatus":400,"path":"workflow.runTask","zodError":{"formErrors":[],"fieldErrors":{"apiParams":[{"code":"custom","message":"参数验证失败 - Array must contain at most 9 element(s)","path":["apiParams","uploadedUrls"],"fatal":true}]}}},"stack":null},"meta":{"values":{"stack":["undefined"]},"v":1}}}]