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_generic_api_error.rs b/crates/api_clients/seedance2pro_client/src/error/seedance2pro_generic_api_error.rs index 6b7572e327..d0a7d1f6f8 100644 --- a/crates/api_clients/seedance2pro_client/src/error/seedance2pro_generic_api_error.rs +++ b/crates/api_clients/seedance2pro_client/src/error/seedance2pro_generic_api_error.rs @@ -16,8 +16,12 @@ pub enum Seedance2ProGenericApiError { /// Includes the original body. SerdeParseErrorWithBodyOnNon200(serde_json::Error, String), - /// An uncategorized bad HTTP response. - UncategorizedBadResponse(String), + /// A response that parsed, but didn't have the shape we expected + /// (eg. an empty batch response array). + UnexpectedResponseShape { + explanation: String, + raw_body: String, + }, /// An uncategorized bad HTTP response with status code and body. UncategorizedBadResponseWithStatusAndBody { @@ -37,7 +41,7 @@ impl Display for Seedance2ProGenericApiError { Self::CloudflareError(err) => write!(f, "Cloudflare error: {}", err), Self::SerdeResponseParseErrorWithBody(err, body) => write!(f, "Failed to parse response body: {:?}. Body: {}", err, body), Self::SerdeParseErrorWithBodyOnNon200(err, body) => write!(f, "Failed to parse non-200 response body: {:?}. Body: {}", err, body), - Self::UncategorizedBadResponse(msg) => write!(f, "Uncategorized bad response: {}", msg), + Self::UnexpectedResponseShape { explanation, raw_body } => write!(f, "Unexpected response shape: {}. Body: {}", explanation, raw_body), Self::UncategorizedBadResponseWithStatusAndBody { status_code, body } => write!(f, "Uncategorized bad response: status code {}, body: {}", status_code, body), Self::WreqError(err) => write!(f, "Wreq client error: {}", err), } 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_character/generate_character.rs b/crates/api_clients/seedance2pro_client/src/requests/generate_character/generate_character.rs index 3bc1f8c812..755aa578b6 100644 --- a/crates/api_clients/seedance2pro_client/src/requests/generate_character/generate_character.rs +++ b/crates/api_clients/seedance2pro_client/src/requests/generate_character/generate_character.rs @@ -119,9 +119,10 @@ pub async fn generate_character(args: GenerateCharacterArgs<'_>) -> Result) -> Result) -> Result) -> Result) -> Result) -> 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..30086ad3d3 --- /dev/null +++ b/crates/api_clients/seedance2pro_client/test_data/responses/prompt_too_long.json @@ -0,0 +1,40 @@ +[ + { + "error": { + "json": { + "message": "[{\"code\": \"custom\",\"message\": \"参数验证失败 - String must contain at most 10000 character(s)\",\"path\": [\"apiParams\",\"prompt\"],\"fatal\": true}]", + "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 + } + } + } +] \ No newline at end of file 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..65a351a35c --- /dev/null +++ b/crates/api_clients/seedance2pro_client/test_data/responses/too_many_urls.json @@ -0,0 +1,40 @@ +[ + { + "error": { + "json": { + "message": "[{\"code\": \"custom\",\"message\": \"参数验证失败 - Array must contain at most 9 element(s)\",\"path\": [\"apiParams\",\"uploadedUrls\"],\"fatal\": true}]", + "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 + } + } + } +] \ No newline at end of file diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/common/map_seedance2pro_web_errors.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/common/map_seedance2pro_web_errors.rs new file mode 100644 index 0000000000..62d737443b --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/common/map_seedance2pro_web_errors.rs @@ -0,0 +1,85 @@ +//! Maps Kinovi (Seedance2Pro) client errors to web errors. +//! +//! Known bad requests (content violations, over-long prompts, too many urls) +//! become 400s with a user-facing message. Everything else is unanticipated +//! and stays a 500. + +use seedance2pro_client::error::seedance2pro_bad_request_api_error::Seedance2ProBadRequestApiError; +use seedance2pro_client::error::seedance2pro_error::Seedance2ProError; + +use crate::http_server::common_responses::common_web_error::CommonWebError; + +/// Map a raw Kinovi client error to a web error. +pub fn map_seedance2pro_error_to_web_error(error: Seedance2ProError) -> CommonWebError { + match error { + Seedance2ProError::ApiBadRequest(bad_request) => { + CommonWebError::BadInputWithSimpleMessage(user_facing_bad_request_message(&bad_request)) + } + other => CommonWebError::from_error(other), + } +} + +fn user_facing_bad_request_message(error: &Seedance2ProBadRequestApiError) -> String { + match error { + Seedance2ProBadRequestApiError::VideoGenerationViolation { .. } => { + "The generation request was flagged as a content violation. \ + Please adjust your prompt or input media and try again.".to_string() + } + Seedance2ProBadRequestApiError::PromptIsTooLong { .. } => { + "The prompt is too long. Please shorten it below 10,000 characters and try again.".to_string() + } + Seedance2ProBadRequestApiError::TooManyUrls { .. } => { + "Too many input files were attached. \ + Please remove some and try again.".to_string() + } + } +} + +#[cfg(test)] +mod tests { + use actix_web::ResponseError; + use seedance2pro_client::error::seedance2pro_specific_api_error::Seedance2ProSpecificApiError; + + use super::*; + + #[test] + fn bad_requests_become_400s_with_user_facing_messages() { + let cases = [ + ( + Seedance2ProBadRequestApiError::VideoGenerationViolation { raw_body: "{}".to_string() }, + "content violation", + ), + ( + Seedance2ProBadRequestApiError::PromptIsTooLong { raw_body: "{}".to_string() }, + "prompt is too long", + ), + ( + Seedance2ProBadRequestApiError::TooManyUrls { raw_body: "{}".to_string() }, + "Too many input files", + ), + ]; + + for (bad_request, expected_message_fragment) in cases { + let error = map_seedance2pro_error_to_web_error( + Seedance2ProError::ApiBadRequest(bad_request)); + + assert_eq!(error.status_code().as_u16(), 400); + match error { + CommonWebError::BadInputWithSimpleMessage(message) => { + assert!( + message.contains(expected_message_fragment), + "message {:?} should contain {:?}", message, expected_message_fragment); + } + other => panic!("Expected BadInputWithSimpleMessage, got: {:?}", other), + } + } + } + + #[test] + fn unanticipated_errors_stay_500s() { + let error = map_seedance2pro_error_to_web_error( + Seedance2ProError::ApiSpecific(Seedance2ProSpecificApiError::UnauthorizedSessionExpired)); + + assert_eq!(error.status_code().as_u16(), 500); + } +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/common/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/common/mod.rs index 6a9b59468d..fc99a874d5 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/common/mod.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/common/mod.rs @@ -1,3 +1,4 @@ pub mod job_failure_test; +pub mod map_seedance2pro_web_errors; pub mod payments_error_test; mod test_synthetic_failure_reason; \ No newline at end of file diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/multi_function/seedance_2p0_multi_function_video_gen_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/multi_function/seedance_2p0_multi_function_video_gen_handler.rs index b5e8dc053b..7dbf99cca6 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/multi_function/seedance_2p0_multi_function_video_gen_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/multi_function/seedance_2p0_multi_function_video_gen_handler.rs @@ -1,9 +1,10 @@ use std::collections::HashMap; use std::sync::Arc; - +use actix_http::StatusCode; use crate::billing::wallets::attempt_wallet_deduction::attempt_wallet_deduction_else_common_web_error; use crate::http_server::common_responses::common_web_error::CommonWebError; use crate::http_server::endpoint_helpers::refund_wallet_after_api_failure::refund_wallet_after_api_failure; +use crate::http_server::endpoints::generate::common::map_seedance2pro_web_errors::map_seedance2pro_error_to_web_error; use crate::http_server::endpoints::generate::common::payments_error_test::payments_error_test; use crate::http_server::session::lookup::user_session_feature_flags::UserSessionFeatureFlags; use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format; @@ -12,7 +13,7 @@ use crate::state::server_state::ServerState; use crate::util::http_download_url_to_bytes::http_download_url_to_bytes; use crate::util::lookup::lookup_media_file_urls_as_map::lookup_media_file_urls_as_map; use actix_web::web::Json; -use actix_web::{web, HttpRequest}; +use actix_web::{web, HttpRequest, ResponseError}; use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::{ Seedance2p0AspectRatio, Seedance2p0BatchCount, Seedance2p0MultiFunctionVideoGenRequest, Seedance2p0MultiFunctionVideoGenResponse, Seedance2p0OutputResolution, @@ -234,13 +235,19 @@ pub async fn seedance_2p0_multi_function_video_gen_handler( } Err(err) => { warn!("Regular session fallback also failed for user {:?}: {:?}", user_token, err); - let notification = NotificationDetailsBuilder::from_boxed_error(err.clone().into()) - .set_title("Seedance 2.0 generation failed (whitelist + fallback)".to_string()) - .set_urgency(Some(NotificationUrgency::High)) - .build(); - if let Err(page_err) = server_state.pager.enqueue_page(notification) { - warn!("Failed to enqueue pager alert: {:?}", page_err); + + // Client-fault errors (eg. content violations, over-long prompts) + // surface to the user as 400s and shouldn't page. + if err.status_code() != StatusCode::BAD_REQUEST { + let notification = NotificationDetailsBuilder::from_boxed_error(err.clone().into()) + .set_title("Seedance 2.0 generation failed (whitelist + fallback)".to_string()) + .set_urgency(Some(NotificationUrgency::High)) + .build(); + if let Err(page_err) = server_state.pager.enqueue_page(notification) { + warn!("Failed to enqueue pager alert: {:?}", page_err); + } } + refund_wallet_after_api_failure(&deduction_result.ledger_entry_token, &mut mysql_connection).await?; return Err(err); } @@ -269,13 +276,19 @@ pub async fn seedance_2p0_multi_function_video_gen_handler( Ok(result) => result, Err(err) => { warn!("Error calling seedance2pro generate_video: {:?}", err); - let notification = NotificationDetailsBuilder::from_boxed_error(err.clone().into()) - .set_title("Seedance 2.0 generation failed".to_string()) - .set_urgency(Some(NotificationUrgency::High)) - .build(); - if let Err(page_err) = server_state.pager.enqueue_page(notification) { - warn!("Failed to enqueue pager alert: {:?}", page_err); + + // Client-fault errors (eg. content violations, over-long prompts) + // surface to the user as 400s and shouldn't page. + if err.status_code() != StatusCode::BAD_REQUEST { + let notification = NotificationDetailsBuilder::from_boxed_error(err.clone().into()) + .set_title("Seedance 2.0 generation failed".to_string()) + .set_urgency(Some(NotificationUrgency::High)) + .build(); + if let Err(page_err) = server_state.pager.enqueue_page(notification) { + warn!("Failed to enqueue pager alert: {:?}", page_err); + } } + refund_wallet_after_api_failure(&deduction_result.ledger_entry_token, &mut mysql_connection).await?; return Err(err); } @@ -613,7 +626,7 @@ async fn upload_and_generate( let gen_response = generate_video(video_gen_args).await .map_err(|err| { warn!("Error calling seedance2pro generate_video: {:?}", err); - CommonWebError::from_error(err) + map_seedance2pro_error_to_web_error(err) })?; Ok(SeedanceGenerationResult { diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/pipeline_v2/run_pipeline_v2.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/pipeline_v2/run_pipeline_v2.rs index 22552a640a..bb1ad6dd77 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/pipeline_v2/run_pipeline_v2.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/pipeline_v2/run_pipeline_v2.rs @@ -18,6 +18,7 @@ use tokens::tokens::users::UserToken; use crate::billing::wallets::attempt_wallet_deduction::attempt_wallet_deduction_else_common_web_error; use crate::http_server::common_responses::common_web_error::CommonWebError; use crate::http_server::endpoints::omni_gen::generate::image::pipeline_result::ImagePipelineResult; +use crate::http_server::endpoints::omni_gen::shared_utils::map_seedance2pro_router_error::map_router_error_to_web_error; use crate::state::server_state::ServerState; use crate::util::lookup::lookup_media_files_as_cdn_url_list_and_map::MediaFilesAsCdnUrlListAndMap; @@ -155,7 +156,7 @@ async fn finalize_and_generate( .await .map_err(|err| { warn!("Failed to finalize image v2 draft: {:?}", err); - CommonWebError::from_error(err) + map_router_error_to_web_error(err) })? } }; @@ -164,7 +165,7 @@ async fn finalize_and_generate( .await .map_err(|err| { warn!("v2 image generation failed: {:?}", err); - CommonWebError::from_error(err) + map_router_error_to_web_error(err) }) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/pipeline_v2/run_pipeline_v2.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/pipeline_v2/run_pipeline_v2.rs index 987270ca93..a772cba319 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/pipeline_v2/run_pipeline_v2.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/pipeline_v2/run_pipeline_v2.rs @@ -19,6 +19,7 @@ use crate::http_server::endpoints::omni_gen::generate::video::helpers::build_rou use crate::http_server::endpoints::omni_gen::generate::video::helpers::pipeline_result::PipelineResult; use crate::http_server::endpoints::omni_gen::generate::video::helpers::resolve_media_tokens_to_urls::resolve_media_tokens_to_urls; use crate::http_server::endpoints::omni_gen::generate::video::kinovi_account::KinoviAccount; +use crate::http_server::endpoints::omni_gen::shared_utils::map_seedance2pro_router_error::map_router_error_to_web_error; use crate::state::server_state::ServerState; pub struct RunPipelineV2Args<'a> { @@ -170,7 +171,7 @@ async fn upload_and_generate( .await .map_err(|err| { warn!("Failed to finalize v2 draft: {:?}", err); - CommonWebError::from_error(err) + map_router_error_to_web_error(err) })? } }; @@ -179,6 +180,6 @@ async fn upload_and_generate( .await .map_err(|err| { warn!("v2 video generation failed: {:?}", err); - CommonWebError::from_error(err) + map_router_error_to_web_error(err) }) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/shared_utils/map_seedance2pro_router_error.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/shared_utils/map_seedance2pro_router_error.rs new file mode 100644 index 0000000000..6815fa3869 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/shared_utils/map_seedance2pro_router_error.rs @@ -0,0 +1,90 @@ +//! Maps artcraft router errors to web errors for the omni_gen pipelines. +//! +//! Known Kinovi (Seedance2Pro) bad requests (content violations, over-long +//! prompts, too many urls) become 400s with a user-facing message. Everything +//! else is unanticipated and stays a 500. + +use artcraft_router::errors::artcraft_router_error::ArtcraftRouterError; +use artcraft_router::errors::provider_error::ProviderError; +use seedance2pro_client::error::seedance2pro_bad_request_api_error::Seedance2ProBadRequestApiError; +use seedance2pro_client::error::seedance2pro_error::Seedance2ProError; + +use crate::http_server::common_responses::common_web_error::CommonWebError; + +/// Map an artcraft router error to a web error, unwrapping Kinovi bad requests. +pub fn map_router_error_to_web_error(error: ArtcraftRouterError) -> CommonWebError { + match error { + ArtcraftRouterError::Provider( + ProviderError::Seedance2Pro(Seedance2ProError::ApiBadRequest(bad_request)) + ) => { + CommonWebError::BadInputWithSimpleMessage(user_facing_bad_request_message(&bad_request)) + } + other => CommonWebError::from_error(other), + } +} + +fn user_facing_bad_request_message(error: &Seedance2ProBadRequestApiError) -> String { + match error { + Seedance2ProBadRequestApiError::VideoGenerationViolation { .. } => { + "The generation request was flagged as a content violation. \ + Please adjust your prompt or input media and try again.".to_string() + } + Seedance2ProBadRequestApiError::PromptIsTooLong { .. } => { + "The prompt is too long. Please shorten it below 10,000 characters and try again.".to_string() + } + Seedance2ProBadRequestApiError::TooManyUrls { .. } => { + "Too many input files were attached. \ + Please remove some and try again.".to_string() + } + } +} + +#[cfg(test)] +mod tests { + use actix_web::ResponseError; + + use super::*; + + #[test] + fn kinovi_bad_requests_become_400s_with_user_facing_messages() { + let cases = [ + ( + Seedance2ProBadRequestApiError::VideoGenerationViolation { raw_body: "{}".to_string() }, + "content violation", + ), + ( + Seedance2ProBadRequestApiError::PromptIsTooLong { raw_body: "{}".to_string() }, + "prompt is too long", + ), + ( + Seedance2ProBadRequestApiError::TooManyUrls { raw_body: "{}".to_string() }, + "Too many input files", + ), + ]; + + for (bad_request, expected_message_fragment) in cases { + let error = map_router_error_to_web_error( + ArtcraftRouterError::Provider( + ProviderError::Seedance2Pro( + Seedance2ProError::ApiBadRequest(bad_request)))); + + assert_eq!(error.status_code().as_u16(), 400); + match error { + CommonWebError::BadInputWithSimpleMessage(message) => { + assert!( + message.contains(expected_message_fragment), + "message {:?} should contain {:?}", message, expected_message_fragment); + } + other => panic!("Expected BadInputWithSimpleMessage, got: {:?}", other), + } + } + } + + #[test] + fn other_router_errors_stay_500s() { + let error = map_router_error_to_web_error( + ArtcraftRouterError::UnsupportedModel("some_model".to_string())); + + assert_eq!(error.status_code().as_u16(), 500); + } +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/shared_utils/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/shared_utils/mod.rs index 8e5d9b9132..cf024715fc 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/shared_utils/mod.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/shared_utils/mod.rs @@ -1 +1,2 @@ -pub mod video; \ No newline at end of file +pub mod map_seedance2pro_router_error; +pub mod video;