Compare commits

...
Author SHA1 Message Date
Brandon Thomas 71677d3891 update failure categories 2026-03-10 19:20:21 -04:00
Brandon Thomas e6c6015309 last improvement to tool 2026-03-10 19:15:46 -04:00
Brandon Thomas 11a4d4fac1 histogram fix 2026-03-10 19:12:23 -04:00
Brandon Thomas 63d7fa4a03 cleanup 2026-03-10 19:06:30 -04:00
Brandon Thomas e1d93ccccd canonicalize command names 2026-03-10 19:04:28 -04:00
Brandon Thomas 6f0c651e41 rename 2026-03-10 18:56:23 -04:00
Brandon Thomas 4ec78bd4f2 move 2026-03-10 18:52:33 -04:00
Brandon Thomas 9f77181345 structured failure messages 2026-03-10 18:49:15 -04:00
Brandon Thomas 355b83dd92 reorganize 2026-03-10 18:35:27 -04:00
Brandon Thomas 7b168e7bca failure categories updated 2026-03-10 18:13:01 -04:00
Brandon Thomas b06ab8ae76 cli 2026-03-10 17:50:19 -04:00
16 changed files with 455 additions and 75 deletions
@@ -0,0 +1,26 @@
use super::failure_type::FailureType;
/// A structured failure reason parsed from the raw Seedance2 Pro API response.
#[derive(Debug, Clone)]
pub struct FailureReason {
/// The original reason string from the API.
pub reason: String,
/// The categorized failure type.
pub failure_type: FailureType,
}
impl FailureReason {
/// Parse a raw failure reason string into a structured `FailureReason`.
///
/// First checks for exact string matches against known reasons,
/// then falls back to case-insensitive substring matching.
pub fn from_reason(reason: &str) -> Self {
let failure_type = FailureType::classify_text(reason);
FailureReason {
reason: reason.to_string(),
failure_type,
}
}
}
@@ -0,0 +1,84 @@
/// Categorized failure type for a Seedance2 Pro order.
#[derive(Debug, Clone, PartialEq)]
pub enum FailureType {
/// User's uploaded image was rejected by platform content rules.
RuleBansUserImage,
/// User's uploaded image was rejected because it contains faces.
RuleBansUserImageWithFaces,
/// User's text prompt was rejected by platform content rules.
RuleBansUserTextPrompt,
/// User's content (image or text) was rejected by platform content rules (generic).
RuleBansUserContent,
/// The generated video was rejected by platform content review.
RuleBansGeneratedVideo,
/// The generated audio was rejected by platform content rules.
RuleBansGeneratedAudio,
/// The generated content (video/audio/other) was rejected by platform content rules (generic).
RuleBansGeneratedContent,
/// Video generation failed (timeout, server error, processing error, etc.)
GenerationFailed,
/// An unrecognized or absent failure reason.
OtherUnknownReason,
}
impl FailureType {
pub fn classify_text(reason: &str) -> Self {
// --- Exact matches first ---
match reason {
"Your uploaded image violates platform rules. Please modify and try again." =>
return Self::RuleBansUserImage,
"Face detected in uploaded media. Please adjust your media and try again." =>
return Self::RuleBansUserImageWithFaces,
"Your input text violates platform rules. Please modify and try again." =>
return Self::RuleBansUserTextPrompt,
"Content violates platform rules. Please modify and try again." =>
return Self::RuleBansUserContent,
"The generated video did not pass review. Credits will not be deducted." =>
return Self::RuleBansGeneratedVideo,
"The generated audio violates platform rules. Please adjust your prompt or images and try again." =>
return Self::RuleBansGeneratedAudio,
"The generated content violates platform rules. Please adjust your prompt or images and try again." =>
return Self::RuleBansGeneratedContent,
"Video generation failed. Please try again." =>
return Self::GenerationFailed,
"Generation timed out. Please try again." =>
return Self::GenerationFailed,
"Server error. Please try again later." =>
return Self::GenerationFailed,
"Your content could not be processed. Please try different images or adjust your prompt." =>
return Self::GenerationFailed,
_ => {}
}
// --- Substring matches (case-insensitive) ---
let lower = reason.to_lowercase();
if lower.contains("face detected") || lower.contains("ensure no faces") {
return Self::RuleBansUserImageWithFaces;
}
if lower.contains("uploaded image violates") {
return Self::RuleBansUserImage;
}
if lower.contains("input text violates") {
return Self::RuleBansUserTextPrompt;
}
if lower.contains("generated video") && lower.contains("not pass review") {
return Self::RuleBansGeneratedVideo;
}
if lower.contains("generated audio") && lower.contains("violates") {
return Self::RuleBansGeneratedAudio;
}
if lower.contains("generated content") && lower.contains("violates") {
return Self::RuleBansGeneratedContent;
}
if lower.contains("content violates") || lower.contains("platform rules") {
return Self::RuleBansUserContent;
}
if lower.contains("video generation failed") || lower.contains("timed out") || lower.contains("server error") {
return Self::GenerationFailed;
}
Self::OtherUnknownReason
}
}
@@ -1,2 +1,4 @@
pub mod failure_reason;
pub mod failure_type;
pub mod poll_orders;
mod request_types;
@@ -2,6 +2,7 @@ use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::error::seedance2pro_client_error::Seedance2ProClientError;
use crate::error::seedance2pro_error::Seedance2ProError;
use crate::error::seedance2pro_generic_api_error::Seedance2ProGenericApiError;
use crate::requests::poll_orders::failure_reason::FailureReason;
use crate::requests::poll_orders::request_types::*;
use crate::utils::common_headers::FIREFOX_USER_AGENT;
use log::info;
@@ -10,17 +11,6 @@ use wreq_util::Emulation;
const GET_ORDERS_BASE_URL: &str = "https://seedance2-pro.com/api/trpc/userOrder.getOrders";
/// Builds the tRPC `input` JSON for the getOrders endpoint.
/// When `cursor` is `Some`, it is included in the JSON payload.
fn build_input_json(cursor: Option<u64>) -> String {
match cursor {
None => r#"{"0":{"json":{"limit":30,"format":null,"direction":"forward"},"meta":{"values":{"format":["undefined"]},"v":1}}}"#.to_string(),
Some(c) => format!(
r#"{{"0":{{"json":{{"limit":30,"format":null,"cursor":{cursor},"direction":"forward"}},"meta":{{"values":{{"format":["undefined"]}},"v":1}}}}}}"#,
cursor = c
),
}
}
// --- Args & response ---
@@ -97,8 +87,8 @@ pub struct OrderStatus {
/// Detailed result entries. Typically one entry per video.
pub results: Vec<VideoResult>,
/// Failure reason. Populated when `task_status` is `Failed`.
pub fail_reason: Option<String>,
/// Structured failure reason. Populated when `task_status` is `Failed` or `fail_reason` is present.
pub fail_reason: Option<FailureReason>,
/// ISO 8601 creation timestamp (e.g. `"2026-02-19T01:20:50.398Z"`).
pub created_at: String,
@@ -172,18 +162,25 @@ pub async fn poll_orders(args: PollOrdersArgs<'_>) -> Result<PollOrdersResponse,
let orders: Vec<OrderStatus> = json.orders
.into_iter()
.map(|o| OrderStatus {
order_id: o.order_id,
task_status: TaskStatus::from_str(&o.task_status),
result_url: o.result_url,
results: o.results.into_iter().map(|r| VideoResult {
url: r.url,
width: r.width,
height: r.height,
// ratio: r.ratio,
}).collect(),
fail_reason: o.fail_reason,
created_at: o.created_at,
.map(|o| {
let task_status = TaskStatus::from_str(&o.task_status);
let fail_reason = match (&o.fail_reason, &task_status) {
(Some(reason), _) => Some(FailureReason::from_reason(reason)),
(None, TaskStatus::Failed) => Some(FailureReason::from_reason("(no reason)")),
_ => None,
};
OrderStatus {
order_id: o.order_id,
task_status,
result_url: o.result_url,
results: o.results.into_iter().map(|r| VideoResult {
url: r.url,
width: r.width,
height: r.height,
}).collect(),
fail_reason,
created_at: o.created_at,
}
})
.collect();
@@ -192,6 +189,18 @@ pub async fn poll_orders(args: PollOrdersArgs<'_>) -> Result<PollOrdersResponse,
Ok(PollOrdersResponse { orders, next_cursor })
}
/// Builds the tRPC `input` JSON for the getOrders endpoint.
/// When `cursor` is `Some`, it is included in the JSON payload.
fn build_input_json(cursor: Option<u64>) -> String {
match cursor {
None => r#"{"0":{"json":{"limit":30,"format":null,"direction":"forward"},"meta":{"values":{"format":["undefined"]},"v":1}}}"#.to_string(),
Some(c) => format!(
r#"{{"0":{{"json":{{"limit":30,"format":null,"cursor":{cursor},"direction":"forward"}},"meta":{{"values":{{"format":["undefined"]}},"v":1}}}}}}"#,
cursor = c
),
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1,27 +1,2 @@
pub mod run;
pub mod seedance2pro;
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(name = "support-tool", about = "Production support CLI")]
pub struct Cli {
#[command(subcommand)]
pub command: TopLevelCommand,
}
#[derive(Subcommand)]
pub enum TopLevelCommand {
/// Seedance2 Pro support commands
Seedance2pro {
#[command(subcommand)]
command: seedance2pro::Seedance2proCommand,
},
}
pub async fn run(cli: Cli) -> anyhow::Result<()> {
match cli.command {
TopLevelCommand::Seedance2pro { command } => {
seedance2pro::run(command).await
}
}
}
@@ -0,0 +1,36 @@
use clap::{Parser, Subcommand};
use super::seedance2pro;
/// All canonical subcommand names across all modules.
/// Used by the underscore-insensitive arg normalizer.
pub fn all_canonical_names() -> Vec<&'static str> {
let mut names: Vec<&str> = vec!["seedance2pro"];
names.extend_from_slice(seedance2pro::dispatch::SUBCOMMAND_NAMES);
names
}
#[derive(Parser)]
#[command(name = "support-tool", about = "Production support CLI")]
pub struct Cli {
#[command(subcommand)]
pub command: TopLevelCommand,
}
#[derive(Subcommand)]
#[command(rename_all = "snake_case")]
pub enum TopLevelCommand {
/// Seedance2 Pro support commands
Seedance2pro {
#[command(subcommand)]
command: seedance2pro::Seedance2proCommand,
},
}
pub async fn run(cli: Cli) -> anyhow::Result<()> {
match cli.command {
TopLevelCommand::Seedance2pro { command } => {
seedance2pro::run(command).await
}
}
}
@@ -4,10 +4,18 @@ use clap::Subcommand;
use super::state::Seedance2ProState;
use super::subcommands;
/// All canonical subcommand names for this module.
/// Used by the underscore-insensitive arg normalizer.
pub const SUBCOMMAND_NAMES: &[&str] = &["find_job", "failed_job_histogram"];
#[derive(Subcommand)]
#[command(rename_all = "snake_case")]
pub enum Seedance2proCommand {
/// Find a job by its order ID across all pages
Findjob(subcommands::findjob::FindjobArgs),
FindJob(subcommands::find_job::FindJobArgs),
/// Scan all jobs and print a histogram of failure reasons
FailedJobHistogram,
}
pub async fn run(command: Seedance2proCommand) -> anyhow::Result<()> {
@@ -17,6 +25,7 @@ pub async fn run(command: Seedance2proCommand) -> anyhow::Result<()> {
let state = Seedance2ProState { cookies };
match command {
Seedance2proCommand::Findjob(args) => subcommands::findjob::run(&state, args).await,
Seedance2proCommand::FindJob(args) => subcommands::find_job::run(&state, args).await,
Seedance2proCommand::FailedJobHistogram => subcommands::failed_job_histogram::run(&state).await,
}
}
@@ -1,4 +1,4 @@
mod dispatch;
pub mod dispatch;
mod state;
mod subcommands;
@@ -0,0 +1,94 @@
use std::collections::BTreeMap;
use anyhow::anyhow;
use log::info;
use seedance2pro::creds::seedance2pro_session::Seedance2ProSession;
use seedance2pro::requests::poll_orders::failure_type::FailureType;
use seedance2pro::requests::poll_orders::poll_orders::{poll_orders, PollOrdersArgs, TaskStatus};
use super::super::state::Seedance2ProState;
pub async fn run(state: &Seedance2ProState) -> anyhow::Result<()> {
let session = Seedance2ProSession::from_cookies_string(state.cookies.clone());
let mut cursor: Option<u64> = None;
let mut page = 0usize;
let mut total_orders = 0usize;
let mut failed_count = 0usize;
let mut histogram: BTreeMap<String, usize> = BTreeMap::new();
loop {
page += 1;
info!("Fetching page {} (cursor: {:?})...", page, cursor);
let result = poll_orders(PollOrdersArgs {
session: &session,
cursor,
}).await
.map_err(|err| anyhow!("Error polling orders on page {}: {:?}", page, err))?;
let page_count = result.orders.len();
total_orders += page_count;
for order in &result.orders {
if order.task_status == TaskStatus::Failed {
failed_count += 1;
let reason = order.fail_reason.as_ref()
.map(|fr| fr.reason.as_str())
.unwrap_or("(no reason)");
*histogram.entry(reason.to_string()).or_insert(0) += 1;
}
}
info!("Page {}: {} orders ({} failed so far).", page, page_count, failed_count);
cursor = result.next_cursor;
if cursor.is_none() {
break;
}
}
println!("\nScanned {} orders across {} pages.", total_orders, page);
println!("Failed: {}\n", failed_count);
if histogram.is_empty() {
println!("No failed jobs found.");
return Ok(());
}
// Sort by count descending.
let mut sorted: Vec<(&String, &usize)> = histogram.iter().collect();
sorted.sort_by(|a, b| b.1.cmp(a.1));
println!("{:<6} {}", "Count", "Failure Reason");
println!("{:<6} {}", "-----", "--------------");
for (reason, count) in &sorted {
println!("{:<6} {}", count, reason);
}
// Build a second histogram by FailureType.
let mut type_histogram: BTreeMap<String, usize> = BTreeMap::new();
for (reason, count) in &histogram {
let failure_type = FailureType::classify_text(reason);
*type_histogram.entry(format!("{:?}", failure_type)).or_insert(0) += count;
}
let mut type_sorted: Vec<(&String, &usize)> = type_histogram.iter().collect();
type_sorted.sort_by(|a, b| b.1.cmp(a.1));
println!("\n{:<6} {}", "Count", "Failure Type");
println!("{:<6} {}", "-----", "------------");
for (failure_type, count) in &type_sorted {
println!("{:<6} {}", count, failure_type);
}
// Print reason → type mapping table.
println!("\n{:<30} {}", "Failure Type", "Failure Reason");
println!("{:<30} {}", "------------", "--------------");
for (reason, _) in &sorted {
let failure_type = FailureType::classify_text(reason);
println!("{:<30} {}", format!("{:?}", failure_type), reason);
}
Ok(())
}
@@ -7,13 +7,13 @@ use seedance2pro::requests::poll_orders::poll_orders::{poll_orders, PollOrdersAr
use super::super::state::Seedance2ProState;
#[derive(Args)]
pub struct FindjobArgs {
pub struct FindJobArgs {
/// The order ID (job token) to search for
#[arg(long)]
pub token: String,
}
pub async fn run(state: &Seedance2ProState, args: FindjobArgs) -> anyhow::Result<()> {
pub async fn run(state: &Seedance2ProState, args: FindJobArgs) -> anyhow::Result<()> {
let session = Seedance2ProSession::from_cookies_string(state.cookies.clone());
let mut cursor: Option<u64> = None;
@@ -53,7 +53,8 @@ fn order_to_json(order: &seedance2pro::requests::poll_orders::poll_orders::Order
"order_id": order.order_id,
"task_status": format!("{:?}", order.task_status),
"result_url": order.result_url,
"fail_reason": order.fail_reason,
"fail_reason": order.fail_reason.as_ref().map(|fr| fr.reason.as_str()),
"failure_type": order.fail_reason.as_ref().map(|fr| format!("{:?}", fr.failure_type)),
"created_at": order.created_at,
"results": order.results.iter().map(|r| {
serde_json::json!({
@@ -1 +1,2 @@
pub mod findjob;
pub mod failed_job_histogram;
pub mod find_job;
@@ -2,12 +2,17 @@ mod commands;
mod utils;
use clap::Parser;
use commands::Cli;
use commands::run::{Cli, all_canonical_names};
use utils::normalize_subcommands::normalize_subcommand_args;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Normalize args so underscores in subcommand names are optional.
let canonical_names = all_canonical_names();
let args = normalize_subcommand_args(std::env::args(), &canonical_names);
// Parse CLI args first so --help works without the env file.
let cli = Cli::parse();
let cli = Cli::parse_from(args);
// Load secrets from .env file.
easyenv::from_filename(".env-support-tool-secrets")
@@ -15,5 +20,5 @@ async fn main() -> anyhow::Result<()> {
easyenv::init_env_logger(Some("info"));
commands::run(cli).await
commands::run::run(cli).await
}
@@ -1 +1 @@
// Shared utilities for support-tool commands.
pub mod normalize_subcommands;
@@ -0,0 +1,27 @@
/// Normalize CLI args so that underscores in subcommand names are optional.
///
/// For each arg that isn't a flag (doesn't start with `-`), if removing all
/// underscores from the arg matches removing all underscores from a canonical
/// name, the arg is replaced with the canonical name.
///
/// This lets users type `findjob`, `find_job`, or even `f_ind_job` and all
/// resolve to the canonical `find_job`.
pub fn normalize_subcommand_args(
args: impl IntoIterator<Item = String>,
canonical_names: &[&str],
) -> Vec<String> {
args.into_iter()
.map(|arg| {
if arg.starts_with('-') {
return arg;
}
let stripped = arg.replace('_', "").to_lowercase();
for &name in canonical_names {
if name.replace('_', "").to_lowercase() == stripped {
return name.to_string();
}
}
arg
})
.collect()
}
@@ -47,6 +47,56 @@ pub enum FrontendFailureCategory {
/// Eg. Seedance 2 will report: "your input text violates platform rules. please modify and try again"
#[serde(rename = "model_rules_violation")]
ModelRulesViolation,
/// Model content violation
/// e.g. "Your uploaded image violates platform rules. Please modify and try again." (seedance2pro)
/// Model content rules prohibit the uploaded image.
#[serde(rename = "rule_bans_user_image")]
RuleBansUserImage,
/// Model content violation
/// Model content rules prohibit user uploaded images containing faces (Seedance 2.0)
/// e.g. "The generated video did not pass review. Credits will not be deducted." (seedance2pro)
#[serde(rename = "rule_bans_user_image_with_faces")]
RuleBansUserImageWithFaces,
/// Model content violation
/// Model content rules prohibit the user's given text prompt (this fails early).
/// e.g. "The generated video did not pass review. Credits will not be deducted." (seedance2pro)
#[serde(rename = "rule_bans_user_text_prompt")]
RuleBansUserTextPrompt,
/// Model content violation
/// Model content rules prohibit user content. (I think this check happens early.)
/// e.g. "Content violates platform rules. Please modify and try again." (seedance2pro)
#[serde(rename = "rule_bans_user_content")]
RuleBansUserContent,
/// Model content violation
/// The video didn't pass checks after it finished generation (this fails at the very end of the generation).
/// e.g. "The generated video did not pass review. Credits will not be deducted." (seedance2pro)
#[serde(rename = "rule_bans_generated_video")]
RuleBansGeneratedVideo,
/// Model content violation
/// The audio (even in video!) didn't pass checks after it finished generation (this fails at the very end of the generation).
/// e.g. "The generated audio violates platform rules. Please adjust your prompt or images and try again." (seedance2pro)
#[serde(rename = "rule_bans_generated_audio")]
RuleBansGeneratedAudio,
/// Model content violation
/// The content didn't pass checks after it finished generation (this fails at the very end of the generation).
/// e.g. "The generated content violates platform rules. Please adjust your prompt or images and try again." (seedance2pro)
#[serde(rename = "rule_bans_generated_content")]
RuleBansGeneratedContent,
/// Generation failed (no reason)
/// Unspecified failure reason
/// Various example failures:
/// - "The generated video did not pass review. Credits will not be deducted." (seedance2pro)
/// - "Server error. Please try again later." (seedance2pro)
#[serde(rename = "generation_failed")]
GenerationFailed,
}
// TODO(bt, 2022-12-21): This desperately needs MySQL integration tests!
@@ -62,6 +112,14 @@ impl FrontendFailureCategory {
Self::NotYetImplemented => "not_yet_implemented",
Self::RetryableWorkerError => "retryable_worker_error",
Self::ModelRulesViolation => "model_rules_violation",
Self::RuleBansUserImage => "rule_bans_user_image",
Self::RuleBansUserImageWithFaces => "rule_bans_user_image_with_faces",
Self::RuleBansUserTextPrompt => "rule_bans_user_text_prompt",
Self::RuleBansUserContent => "rule_bans_user_content",
Self::RuleBansGeneratedVideo => "rule_bans_generated_video",
Self::RuleBansGeneratedAudio => "rule_bans_generated_audio",
Self::RuleBansGeneratedContent => "rule_bans_generated_content",
Self::GenerationFailed => "generation_failed",
}
}
@@ -72,6 +130,14 @@ impl FrontendFailureCategory {
"not_yet_implemented" => Ok(Self::NotYetImplemented),
"retryable_worker_error" => Ok(Self::RetryableWorkerError),
"model_rules_violation" => Ok(Self::ModelRulesViolation),
"rule_bans_user_image" => Ok(Self::RuleBansUserImage),
"rule_bans_user_image_with_faces" => Ok(Self::RuleBansUserImageWithFaces),
"rule_bans_user_text_prompt" => Ok(Self::RuleBansUserTextPrompt),
"rule_bans_user_content" => Ok(Self::RuleBansUserContent),
"rule_bans_generated_video" => Ok(Self::RuleBansGeneratedVideo),
"rule_bans_generated_audio" => Ok(Self::RuleBansGeneratedAudio),
"rule_bans_generated_content" => Ok(Self::RuleBansGeneratedContent),
"generation_failed" => Ok(Self::GenerationFailed),
_ => Err(format!("invalid value: {:?}", value)),
}
}
@@ -85,6 +151,14 @@ impl FrontendFailureCategory {
Self::NotYetImplemented,
Self::RetryableWorkerError,
Self::ModelRulesViolation,
Self::RuleBansUserImage,
Self::RuleBansUserImageWithFaces,
Self::RuleBansUserTextPrompt,
Self::RuleBansUserContent,
Self::RuleBansGeneratedVideo,
Self::RuleBansGeneratedAudio,
Self::RuleBansGeneratedContent,
Self::GenerationFailed,
])
}
}
@@ -104,6 +178,14 @@ mod tests {
assert_serialization(FrontendFailureCategory::NotYetImplemented, "not_yet_implemented");
assert_serialization(FrontendFailureCategory::RetryableWorkerError, "retryable_worker_error");
assert_serialization(FrontendFailureCategory::ModelRulesViolation, "model_rules_violation");
assert_serialization(FrontendFailureCategory::RuleBansUserImage, "rule_bans_user_image");
assert_serialization(FrontendFailureCategory::RuleBansUserImageWithFaces, "rule_bans_user_image_with_faces");
assert_serialization(FrontendFailureCategory::RuleBansUserTextPrompt, "rule_bans_user_text_prompt");
assert_serialization(FrontendFailureCategory::RuleBansUserContent, "rule_bans_user_content");
assert_serialization(FrontendFailureCategory::RuleBansGeneratedVideo, "rule_bans_generated_video");
assert_serialization(FrontendFailureCategory::RuleBansGeneratedAudio, "rule_bans_generated_audio");
assert_serialization(FrontendFailureCategory::RuleBansGeneratedContent, "rule_bans_generated_content");
assert_serialization(FrontendFailureCategory::GenerationFailed, "generation_failed");
}
#[test]
@@ -113,6 +195,14 @@ mod tests {
assert_eq!(FrontendFailureCategory::NotYetImplemented.to_str(), "not_yet_implemented");
assert_eq!(FrontendFailureCategory::RetryableWorkerError.to_str(), "retryable_worker_error");
assert_eq!(FrontendFailureCategory::ModelRulesViolation.to_str(), "model_rules_violation");
assert_eq!(FrontendFailureCategory::RuleBansUserImage.to_str(), "rule_bans_user_image");
assert_eq!(FrontendFailureCategory::RuleBansUserImageWithFaces.to_str(), "rule_bans_user_image_with_faces");
assert_eq!(FrontendFailureCategory::RuleBansUserTextPrompt.to_str(), "rule_bans_user_text_prompt");
assert_eq!(FrontendFailureCategory::RuleBansUserContent.to_str(), "rule_bans_user_content");
assert_eq!(FrontendFailureCategory::RuleBansGeneratedVideo.to_str(), "rule_bans_generated_video");
assert_eq!(FrontendFailureCategory::RuleBansGeneratedAudio.to_str(), "rule_bans_generated_audio");
assert_eq!(FrontendFailureCategory::RuleBansGeneratedContent.to_str(), "rule_bans_generated_content");
assert_eq!(FrontendFailureCategory::GenerationFailed.to_str(), "generation_failed");
}
#[test]
@@ -122,17 +212,34 @@ mod tests {
assert_eq!(FrontendFailureCategory::from_str("not_yet_implemented").unwrap(), FrontendFailureCategory::NotYetImplemented);
assert_eq!(FrontendFailureCategory::from_str("retryable_worker_error").unwrap(), FrontendFailureCategory::RetryableWorkerError);
assert_eq!(FrontendFailureCategory::from_str("model_rules_violation").unwrap(), FrontendFailureCategory::ModelRulesViolation);
assert_eq!(FrontendFailureCategory::from_str("rule_bans_user_image").unwrap(), FrontendFailureCategory::RuleBansUserImage);
assert_eq!(FrontendFailureCategory::from_str("rule_bans_user_image_with_faces").unwrap(), FrontendFailureCategory::RuleBansUserImageWithFaces);
assert_eq!(FrontendFailureCategory::from_str("rule_bans_user_text_prompt").unwrap(), FrontendFailureCategory::RuleBansUserTextPrompt);
assert_eq!(FrontendFailureCategory::from_str("rule_bans_user_content").unwrap(), FrontendFailureCategory::RuleBansUserContent);
assert_eq!(FrontendFailureCategory::from_str("rule_bans_generated_video").unwrap(), FrontendFailureCategory::RuleBansGeneratedVideo);
assert_eq!(FrontendFailureCategory::from_str("rule_bans_generated_audio").unwrap(), FrontendFailureCategory::RuleBansGeneratedAudio);
assert_eq!(FrontendFailureCategory::from_str("rule_bans_generated_content").unwrap(), FrontendFailureCategory::RuleBansGeneratedContent);
assert_eq!(FrontendFailureCategory::from_str("generation_failed").unwrap(), FrontendFailureCategory::GenerationFailed);
assert_eq!(FrontendFailureCategory::from_str("invalid_value").is_err(), true);
}
#[test]
fn all_variants() {
let mut variants = FrontendFailureCategory::all_variants();
assert_eq!(variants.len(), 5);
assert_eq!(variants.len(), 13);
assert_eq!(variants.pop_first(), Some(FrontendFailureCategory::FaceNotDetected));
assert_eq!(variants.pop_first(), Some(FrontendFailureCategory::KeepAliveElapsed));
assert_eq!(variants.pop_first(), Some(FrontendFailureCategory::NotYetImplemented));
assert_eq!(variants.pop_first(), Some(FrontendFailureCategory::RetryableWorkerError));
assert_eq!(variants.pop_first(), Some(FrontendFailureCategory::ModelRulesViolation));
assert_eq!(variants.pop_first(), Some(FrontendFailureCategory::RuleBansUserImage));
assert_eq!(variants.pop_first(), Some(FrontendFailureCategory::RuleBansUserImageWithFaces));
assert_eq!(variants.pop_first(), Some(FrontendFailureCategory::RuleBansUserTextPrompt));
assert_eq!(variants.pop_first(), Some(FrontendFailureCategory::RuleBansUserContent));
assert_eq!(variants.pop_first(), Some(FrontendFailureCategory::RuleBansGeneratedVideo));
assert_eq!(variants.pop_first(), Some(FrontendFailureCategory::RuleBansGeneratedAudio));
assert_eq!(variants.pop_first(), Some(FrontendFailureCategory::RuleBansGeneratedContent));
assert_eq!(variants.pop_first(), Some(FrontendFailureCategory::GenerationFailed));
assert_eq!(variants.pop_first(), None);
}
}
@@ -4,6 +4,7 @@ use enums::by_table::generic_inference_jobs::frontend_failure_category::Frontend
use mysql_queries::queries::generic_inference::job::mark_job_failed_by_token::{mark_job_failed_by_token, MarkJobFailedByTokenArgs};
use mysql_queries::queries::generic_inference::seedance2pro::list_pending_seedance2pro_jobs::PendingSeedance2ProJob;
use mysql_queries::queries::wallets::refund::try_to_refund_ledger_entry::{try_to_refund_ledger_entry, WalletRefundOutcome};
use seedance2pro::requests::poll_orders::failure_type::FailureType;
use seedance2pro::requests::poll_orders::poll_orders::OrderStatus;
use crate::job_dependencies::JobDependencies;
@@ -15,7 +16,8 @@ pub async fn process_failed_job(
) {
let reason = order
.fail_reason
.as_deref()
.as_ref()
.map(|fr| fr.reason.as_str())
.unwrap_or("unknown failure reason");
// --- Step 1: Attempt the refund before touching the job status. ---
@@ -89,17 +91,19 @@ pub async fn process_failed_job(
// --- Step 2: Mark the job record as failed. ---
let reason_lower = reason.to_lowercase();
let platform_rules_violation = reason_lower.contains("violates") ||
reason_lower.contains("platform rules") ||
reason_lower.contains("please modify");
let frontend_failure_category = if platform_rules_violation {
Some(FrontendFailureCategory::ModelRulesViolation)
} else {
None
};
let frontend_failure_category = order.fail_reason.as_ref().map(|fr| {
match fr.failure_type {
FailureType::RuleBansUserImage => FrontendFailureCategory::RuleBansUserImage,
FailureType::RuleBansUserImageWithFaces => FrontendFailureCategory::RuleBansUserImageWithFaces,
FailureType::RuleBansUserTextPrompt => FrontendFailureCategory::RuleBansUserTextPrompt,
FailureType::RuleBansUserContent => FrontendFailureCategory::RuleBansUserContent,
FailureType::RuleBansGeneratedVideo => FrontendFailureCategory::RuleBansGeneratedVideo,
FailureType::RuleBansGeneratedAudio => FrontendFailureCategory::RuleBansGeneratedAudio,
FailureType::RuleBansGeneratedContent => FrontendFailureCategory::RuleBansGeneratedContent,
FailureType::GenerationFailed => FrontendFailureCategory::GenerationFailed,
FailureType::OtherUnknownReason => FrontendFailureCategory::GenerationFailed,
}
});
warn!(
"Order {} failed: {}. Marking job {} failed.",