change grok failure status shape

This commit is contained in:
Brandon Thomas committed 2026-06-02 19:52:49 -04:00
1 parent 1d8f64dd23
commit a58db831b1
4 files changed
+356 -187

No files matched your search

@@ -1,10 +1,11 @@
use log::info;
use serde_derive::Serialize;
use serde_json::Value as JsonValue;
use crate::api::requests::videos::video_status::request_types::*;
use crate::api::requests::xai_host::XAI_API_BASE_URL;
use crate::creds::grok_api_key::GrokApiKey;
use crate::error::classify_grok_http_error::classify_grok_http_error;
use crate::error::classify_grok_http_error::{body_indicates_moderation, classify_grok_http_error};
use crate::error::grok_client_error::GrokClientError;
use crate::error::grok_error::GrokError;
use crate::error::grok_generic_api_error::GrokGenericApiError;
@@ -31,31 +32,46 @@ pub struct VideoStatusRequest {
// ── Public response ──
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VideoStatusState {
/// Still working.
Pending,
/// Finished successfully — `video.url` is populated.
Done,
/// xAI couldn't render this — see `VideoJobFailed` in the error path.
Failed,
#[derive(Debug, Clone)]
pub struct VideoStatusResponse {
pub status: VideoStatus,
}
/// The lifecycle state of a video job. `Failed` is a *status*, not an error —
/// xAI accepted the job and (usually) billed for it, but didn't produce a
/// usable video.
#[derive(Debug, Clone)]
pub struct VideoStatusSuccess {
pub state: VideoStatusState,
pub enum VideoStatus {
/// Still working. `progress` is 0–100 when xAI provides it.
Pending {
progress: Option<u8>,
},
/// Finished successfully — `video.url` is typically populated.
Complete {
model: Option<String>,
video: Option<VideoOutputInfo>,
cost_in_usd_ticks: Option<u64>,
},
/// xAI couldn't render this. `reason` is our best classification; the
/// remaining fields surface whatever xAI told us so the caller can log,
/// mark the row failed, and (where applicable) refund / surface a
/// user-facing message.
Failed {
reason: FailureReason,
code: Option<String>,
error: Option<String>,
cost_in_usd_ticks: Option<u64>,
full_error_json_payload: Option<String>,
},
}
/// 0–100, may be `None` while still queued.
pub progress: Option<u8>,
/// The model that fulfilled the request.
pub model: Option<String>,
/// Populated when `state == Done`.
pub video: Option<VideoOutputInfo>,
/// xAI cost reporting (in micro-dollars / "USD ticks").
pub cost_in_usd_ticks: Option<u64>,
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FailureReason {
/// xAI's safety filter rejected the prompt or the generated video.
ContentModerated,
/// Anything else: invalid argument we can't recover from, internal error,
/// expired job, etc.
Unknown,
}
#[derive(Debug, Clone)]
@@ -72,15 +88,13 @@ pub struct VideoOutputInfo {
/// GET https://api.x.ai/v1/videos/{request_id} — poll a video generation /
/// edit / extension job for its current status.
///
/// Returns:
/// - `Ok(VideoStatusSuccess { state: Pending, .. })` while the job is in flight
/// - `Ok(VideoStatusSuccess { state: Done, video: Some(..), .. })` when finished
/// - `Err(GrokSpecificApiError::VideoJobFailed { code, message })` when xAI
/// reports the job as failed
/// - `Err(GrokSpecificApiError::VideoJobExpired)` when the job expired
/// Returns `Ok(VideoStatusResponse)` for any well-formed xAI response,
/// including jobs that failed or were moderated — those come back as
/// `VideoStatus::Failed`. Only infrastructure problems (auth, network, 404
/// for unknown request_id, 5xx) bubble up as `Err(GrokError)`.
///
/// Docs: <https://docs.x.ai/developers/model-capabilities/video/generation>
pub async fn video_status(args: VideoStatusArgs<'_>) -> Result<VideoStatusSuccess, GrokError> {
pub async fn video_status(args: VideoStatusArgs<'_>) -> Result<VideoStatusResponse, GrokError> {
let req = args.request;
let url = format!("{}/v1/videos/{}", XAI_API_BASE_URL, req.request_id);
@@ -98,55 +112,155 @@ pub async fn video_status(args: VideoStatusArgs<'_>) -> Result<VideoStatusSucces
.await
.map_err(GrokGenericApiError::ReqwestError)?;
let status = response.status();
let http_status = response.status();
let response_body = response.text()
.await
.map_err(GrokGenericApiError::ReqwestError)?;
info!("Grok video_status response: http_status={}", status);
info!("Grok video_status response: http_status={}", http_status);
classify_grok_http_error(status, Some(&response_body))?;
if http_status.is_success() {
let parsed: VideoStatusResponseBody = serde_json::from_str(&response_body)
.map_err(|err| GrokGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.clone()))?;
return classify_status_field(&parsed, &response_body);
}
let parsed: VideoStatusResponseBody = serde_json::from_str(&response_body)
.map_err(|err| GrokGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.clone()))?;
// Non-2xx. xAI returns 4xx for some failure modes that are really "the job
// failed" rather than "something is wrong with your request" — most
// notably content moderation. Recognize those and return them as a Failed
// status so callers don't have to thread an error path.
if body_indicates_moderation(&response_body) {
return Ok(build_moderated_failed_response(&response_body));
}
classify_status_field(&parsed)
// Anything else (401, 402, 404, 429, plain 400/403, 5xx) is a real error.
Err(unwrap_classify_err(classify_grok_http_error(http_status, Some(&response_body))))
}
fn classify_status_field(parsed: &VideoStatusResponseBody) -> Result<VideoStatusSuccess, GrokError> {
let state = match parsed.status.as_str() {
"pending" | "queued" | "in_progress" | "processing" => VideoStatusState::Pending,
"done" | "completed" | "succeeded" => VideoStatusState::Done,
fn classify_status_field(
parsed: &VideoStatusResponseBody,
raw_body: &str,
) -> Result<VideoStatusResponse, GrokError> {
let cost = parsed.usage.as_ref().and_then(|u| u.cost_in_usd_ticks);
match parsed.status.as_str() {
"pending" | "queued" | "in_progress" | "processing" => Ok(VideoStatusResponse {
status: VideoStatus::Pending { progress: parsed.progress },
}),
"done" | "completed" | "succeeded" => {
let video = parsed.video.as_ref().map(|v| VideoOutputInfo {
url: v.url.clone(),
duration: v.duration,
respect_moderation: v.respect_moderation,
});
Ok(VideoStatusResponse {
status: VideoStatus::Complete {
model: parsed.model.clone(),
video,
cost_in_usd_ticks: cost,
},
})
}
"failed" => {
let (code, message) = parsed.error.as_ref()
.map(|e| (e.code.clone(), e.message.clone()))
.unwrap_or_else(|| ("unknown".to_string(), "video job failed without details".to_string()));
return Err(crate::error::grok_specific_api_error::GrokSpecificApiError::VideoJobFailed { code, message }.into());
}
"expired" => {
return Err(crate::error::grok_specific_api_error::GrokSpecificApiError::VideoJobExpired.into());
}
other => {
return Err(GrokGenericApiError::UncategorizedBadResponseWithStatusAndBody {
status_code: reqwest::StatusCode::OK,
body: format!("unknown video status: {:?}", other),
}.into());
let code = parsed.error.as_ref().map(|e| e.code.clone());
let error_msg = parsed.error.as_ref().map(|e| e.message.clone());
let reason = if text_indicates_moderation(error_msg.as_deref())
|| body_indicates_moderation(raw_body)
{
FailureReason::ContentModerated
} else {
FailureReason::Unknown
};
Ok(VideoStatusResponse {
status: VideoStatus::Failed {
reason,
code,
error: error_msg,
cost_in_usd_ticks: cost,
full_error_json_payload: Some(raw_body.to_string()),
},
})
}
"expired" => Ok(VideoStatusResponse {
status: VideoStatus::Failed {
reason: FailureReason::Unknown,
code: Some("expired".to_string()),
error: Some("video job expired".to_string()),
cost_in_usd_ticks: cost,
full_error_json_payload: Some(raw_body.to_string()),
},
}),
other => Err(GrokGenericApiError::UncategorizedBadResponseWithStatusAndBody {
status_code: reqwest::StatusCode::OK,
body: format!("unknown video status: {:?}", other),
}.into()),
}
}
fn build_moderated_failed_response(raw_body: &str) -> VideoStatusResponse {
let (code, error_msg, cost) = parse_error_body(raw_body);
VideoStatusResponse {
status: VideoStatus::Failed {
reason: FailureReason::ContentModerated,
code,
error: error_msg,
cost_in_usd_ticks: cost,
full_error_json_payload: Some(raw_body.to_string()),
},
}
}
/// Best-effort extraction of `{code, error, usage.cost_in_usd_ticks}` from
/// the two error-body shapes xAI uses:
///
/// 1. Status-poll 4xx:
/// `{"code":"...", "error":"<string>", "usage":{"cost_in_usd_ticks":...}}`
/// 2. OpenAI-compatible:
/// `{"error":{"code":"...", "message":"..."}}`
fn parse_error_body(body: &str) -> (Option<String>, Option<String>, Option<u64>) {
let parsed: JsonValue = match serde_json::from_str(body) {
Ok(v) => v,
Err(_) => return (None, None, None),
};
let video = parsed.video.as_ref().map(|v| VideoOutputInfo {
url: v.url.clone(),
duration: v.duration,
respect_moderation: v.respect_moderation,
});
let top_level_code = parsed.get("code")
.and_then(JsonValue::as_str)
.map(str::to_string);
Ok(VideoStatusSuccess {
state,
progress: parsed.progress,
model: parsed.model.clone(),
video,
cost_in_usd_ticks: parsed.usage.as_ref().and_then(|u| u.cost_in_usd_ticks),
})
let (error_msg, nested_code) = match parsed.get("error") {
Some(JsonValue::String(s)) => (Some(s.clone()), None),
Some(obj @ JsonValue::Object(_)) => {
let msg = obj.get("message").and_then(JsonValue::as_str).map(str::to_string);
let nested = obj.get("code").and_then(JsonValue::as_str).map(str::to_string);
(msg, nested)
}
_ => (None, None),
};
let code = top_level_code.or(nested_code);
let cost = parsed.get("usage")
.and_then(|u| u.get("cost_in_usd_ticks"))
.and_then(JsonValue::as_u64);
(code, error_msg, cost)
}
fn text_indicates_moderation(maybe_text: Option<&str>) -> bool {
maybe_text.map(body_indicates_moderation).unwrap_or(false)
}
/// `classify_grok_http_error` always returns `Err` when the status is non-2xx,
/// so this is infallible in our call path. Spelled out for clarity at the call
/// site rather than `.unwrap_err()` on the inline expression.
fn unwrap_classify_err(result: Result<(), GrokError>) -> GrokError {
match result {
Ok(()) => GrokGenericApiError::UncategorizedBadResponseWithStatusAndBody {
status_code: reqwest::StatusCode::OK,
body: "classify_grok_http_error returned Ok for a non-2xx response".to_string(),
}.into(),
Err(err) => err,
}
}
#[cfg(test)]
@@ -156,36 +270,30 @@ mod tests {
use crate::error::grok_specific_api_error::GrokSpecificApiError;
// ── Shape tests ──
// ── Status-field dispatch (HTTP 200) ──
#[test]
fn pending_response_classifies_as_pending() {
let json = r#"{ "status": "pending", "progress": 12, "model": "grok-imagine-video" }"#;
let parsed: VideoStatusResponseBody = serde_json::from_str(json).unwrap();
let result = classify_status_field(&parsed).unwrap();
assert_eq!(result.state, VideoStatusState::Pending);
assert_eq!(result.progress, Some(12));
assert_eq!(result.model.as_deref(), Some("grok-imagine-video"));
assert!(result.video.is_none());
let result = classify_status_field(&parsed, json).unwrap();
match result.status {
VideoStatus::Pending { progress } => assert_eq!(progress, Some(12)),
other => panic!("expected Pending, got: {:?}", other),
}
}
#[test]
fn pending_response_with_v1p5_model_passes_model_string_through() {
// The `model` field is opaque Option<String>; the v1.5 preview model
// identifier (and its dated alias) flow through unchanged.
fn pending_response_with_v1p5_model_does_not_break_dispatch() {
// `model` doesn't surface on Pending; it flows through on Complete only.
let json = r#"{ "status": "pending", "progress": 5, "model": "grok-imagine-video-1.5-preview" }"#;
let parsed: VideoStatusResponseBody = serde_json::from_str(json).unwrap();
let result = classify_status_field(&parsed).unwrap();
assert_eq!(result.model.as_deref(), Some("grok-imagine-video-1.5-preview"));
let json_alias = r#"{ "status": "pending", "progress": 5, "model": "grok-imagine-video-1.5-2026-05-30" }"#;
let parsed_alias: VideoStatusResponseBody = serde_json::from_str(json_alias).unwrap();
let result_alias = classify_status_field(&parsed_alias).unwrap();
assert_eq!(result_alias.model.as_deref(), Some("grok-imagine-video-1.5-2026-05-30"));
let result = classify_status_field(&parsed, json).unwrap();
assert!(matches!(result.status, VideoStatus::Pending { .. }));
}
#[test]
fn done_response_classifies_as_done_with_video() {
fn done_response_classifies_as_complete_with_video() {
let json = r#"{
"status": "done",
"progress": 100,
@@ -194,62 +302,150 @@ mod tests {
"usage": { "cost_in_usd_ticks": 42 }
}"#;
let parsed: VideoStatusResponseBody = serde_json::from_str(json).unwrap();
let result = classify_status_field(&parsed).unwrap();
assert_eq!(result.state, VideoStatusState::Done);
let video = result.video.unwrap();
assert_eq!(video.url.as_deref(), Some("https://imagine.x.ai/v.mp4"));
assert_eq!(video.duration, Some(5));
assert_eq!(video.respect_moderation, Some(true));
assert_eq!(result.cost_in_usd_ticks, Some(42));
let result = classify_status_field(&parsed, json).unwrap();
match result.status {
VideoStatus::Complete { model, video, cost_in_usd_ticks } => {
assert_eq!(model.as_deref(), Some("grok-imagine-video"));
let video = video.unwrap();
assert_eq!(video.url.as_deref(), Some("https://imagine.x.ai/v.mp4"));
assert_eq!(video.duration, Some(5));
assert_eq!(video.respect_moderation, Some(true));
assert_eq!(cost_in_usd_ticks, Some(42));
}
other => panic!("expected Complete, got: {:?}", other),
}
}
#[test]
fn failed_response_returns_video_job_failed_error() {
fn failed_response_returns_ok_failed_unknown() {
let json = r#"{
"status": "failed",
"progress": 50,
"error": { "code": "invalid_argument", "message": "duration too long" }
"error": { "code": "invalid_argument", "message": "duration too long" },
"usage": { "cost_in_usd_ticks": 7 }
}"#;
let parsed: VideoStatusResponseBody = serde_json::from_str(json).unwrap();
let err = classify_status_field(&parsed).unwrap_err();
match err {
GrokError::ApiSpecific(GrokSpecificApiError::VideoJobFailed { code, message }) => {
assert_eq!(code, "invalid_argument");
assert_eq!(message, "duration too long");
let result = classify_status_field(&parsed, json).unwrap();
match result.status {
VideoStatus::Failed { reason, code, error, cost_in_usd_ticks, full_error_json_payload } => {
assert_eq!(reason, FailureReason::Unknown);
assert_eq!(code.as_deref(), Some("invalid_argument"));
assert_eq!(error.as_deref(), Some("duration too long"));
assert_eq!(cost_in_usd_ticks, Some(7));
assert_eq!(full_error_json_payload.as_deref(), Some(json));
}
other => panic!("expected VideoJobFailed, got: {:?}", other),
other => panic!("expected Failed, got: {:?}", other),
}
}
#[test]
fn failed_response_without_error_object_uses_unknown() {
fn failed_response_with_moderation_message_is_classified_as_content_moderated() {
let json = r#"{
"status": "failed",
"error": { "code": "permission_denied", "message": "Video rejected by safety moderation" },
"usage": { "cost_in_usd_ticks": 11 }
}"#;
let parsed: VideoStatusResponseBody = serde_json::from_str(json).unwrap();
let result = classify_status_field(&parsed, json).unwrap();
match result.status {
VideoStatus::Failed { reason, code, cost_in_usd_ticks, .. } => {
assert_eq!(reason, FailureReason::ContentModerated);
assert_eq!(code.as_deref(), Some("permission_denied"));
assert_eq!(cost_in_usd_ticks, Some(11));
}
other => panic!("expected Failed, got: {:?}", other),
}
}
#[test]
fn failed_response_without_error_object_yields_none_code_and_error() {
let json = r#"{ "status": "failed", "progress": 0 }"#;
let parsed: VideoStatusResponseBody = serde_json::from_str(json).unwrap();
let err = classify_status_field(&parsed).unwrap_err();
match err {
GrokError::ApiSpecific(GrokSpecificApiError::VideoJobFailed { code, .. }) => {
assert_eq!(code, "unknown");
let result = classify_status_field(&parsed, json).unwrap();
match result.status {
VideoStatus::Failed { reason, code, error, .. } => {
assert_eq!(reason, FailureReason::Unknown);
assert!(code.is_none());
assert!(error.is_none());
}
other => panic!("expected VideoJobFailed(unknown), got: {:?}", other),
other => panic!("expected Failed, got: {:?}", other),
}
}
#[test]
fn expired_response_returns_expired_error() {
let json = r#"{ "status": "expired" }"#;
fn expired_response_classifies_as_failed_unknown() {
let json = r#"{ "status": "expired", "usage": { "cost_in_usd_ticks": 3 } }"#;
let parsed: VideoStatusResponseBody = serde_json::from_str(json).unwrap();
let err = classify_status_field(&parsed).unwrap_err();
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::VideoJobExpired)));
let result = classify_status_field(&parsed, json).unwrap();
match result.status {
VideoStatus::Failed { reason, code, cost_in_usd_ticks, .. } => {
assert_eq!(reason, FailureReason::Unknown);
assert_eq!(code.as_deref(), Some("expired"));
assert_eq!(cost_in_usd_ticks, Some(3));
}
other => panic!("expected Failed, got: {:?}", other),
}
}
#[test]
fn unknown_status_field_returns_generic_error() {
let json = r#"{ "status": "magnificent" }"#;
let parsed: VideoStatusResponseBody = serde_json::from_str(json).unwrap();
let err = classify_status_field(&parsed).unwrap_err();
let err = classify_status_field(&parsed, json).unwrap_err();
assert!(matches!(err, GrokError::ApiGeneric(_)));
}
// ── Error-body dispatch (HTTP 4xx) ──
#[test]
fn moderation_4xx_body_becomes_failed_content_moderated() {
// The real-world body that previously surfaced as
// `Err(GrokSpecificApiError::PromptModerated(..))`. It should now be
// an Ok(Failed { ContentModerated }) with all fields populated from
// the JSON.
let body = r#"{"code":"Client specified an invalid argument","error":"Generated video rejected by content moderation.","usage":{"cost_in_usd_ticks":11300000000}}"#;
let response = build_moderated_failed_response(body);
match response.status {
VideoStatus::Failed { reason, code, error, cost_in_usd_ticks, full_error_json_payload } => {
assert_eq!(reason, FailureReason::ContentModerated);
assert_eq!(code.as_deref(), Some("Client specified an invalid argument"));
assert_eq!(error.as_deref(), Some("Generated video rejected by content moderation."));
assert_eq!(cost_in_usd_ticks, Some(11_300_000_000));
assert_eq!(full_error_json_payload.as_deref(), Some(body));
}
other => panic!("expected Failed, got: {:?}", other),
}
}
#[test]
fn moderation_openai_shape_body_extracts_nested_code_and_message() {
let body = r#"{"error":{"code":"content_policy","message":"Content moderation blocked this prompt"}}"#;
let response = build_moderated_failed_response(body);
match response.status {
VideoStatus::Failed { reason, code, error, .. } => {
assert_eq!(reason, FailureReason::ContentModerated);
assert_eq!(code.as_deref(), Some("content_policy"));
assert_eq!(error.as_deref(), Some("Content moderation blocked this prompt"));
}
other => panic!("expected Failed, got: {:?}", other),
}
}
#[test]
fn moderation_unparseable_body_still_yields_failed_with_payload() {
let body = "totally not json but mentions moderation";
let response = build_moderated_failed_response(body);
match response.status {
VideoStatus::Failed { reason, code, error, full_error_json_payload, .. } => {
assert_eq!(reason, FailureReason::ContentModerated);
assert!(code.is_none());
assert!(error.is_none());
assert_eq!(full_error_json_payload.as_deref(), Some(body));
}
other => panic!("expected Failed, got: {:?}", other),
}
}
// ── Public Request shape ──
#[test]
@@ -284,7 +480,7 @@ mod tests {
},
}).await;
println!("Result: {:?}", result.as_ref().map(|s| (&s.state, s.progress)));
println!("Result: {:?}", result.as_ref().map(|r| &r.status));
let err = result.unwrap_err();
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::NotFound)),
"expected NotFound, got: {:?}", err);
@@ -292,8 +488,8 @@ mod tests {
}
/// Polls a known request_id and prints the result. Doesn't assert a
/// specific state — depending on when the test runs, the job may be
/// pending, done, failed, or expired. The point is to exercise the
/// specific status — depending on when the test runs, the job may be
/// pending, complete, or failed. The point is to exercise the
/// happy-path against the real xAI API.
#[tokio::test]
#[ignore] // manually test — requires real API key
@@ -306,26 +502,19 @@ mod tests {
let result = video_status(VideoStatusArgs {
api_key: &api_key,
request: VideoStatusRequest {
//request_id: "e397ac83-c22f-90b1-9831-900c01497345".to_string(),
request_id: "ce681bd0-133d-9cf3-975c-422d292d4e8e".to_string(),
},
}).await;
match &result {
Ok(s) => println!(
"status: state={:?} progress={:?} model={:?} video={:?} cost_in_usd_ticks={:?}",
s.state, s.progress, s.model, s.video, s.cost_in_usd_ticks,
),
Ok(r) => println!("status: {:?}", r.status),
Err(e) => println!("status error: {:?}", e),
}
// No assertion on the specific state — the job may have completed,
// failed, or expired by the time this runs. We just want to confirm
// the request_id was accepted and parsed.
Ok(())
}
/// Polls another known request_id. Same lax assertions as the test above
/// — state varies with time.
/// — status varies with time.
#[tokio::test]
#[ignore] // manually test — requires real API key
async fn live_test_video_status_known_id_2() -> AnyhowResult<()> {
@@ -342,10 +531,7 @@ mod tests {
}).await;
match &result {
Ok(s) => println!(
"status: state={:?} progress={:?} model={:?} video={:?} cost_in_usd_ticks={:?}",
s.state, s.progress, s.model, s.video, s.cost_in_usd_ticks,
),
Ok(r) => println!("status: {:?}", r.status),
Err(e) => println!("status error: {:?}", e),
}
Ok(())
@@ -363,17 +549,12 @@ mod tests {
let result = video_status(VideoStatusArgs {
api_key: &api_key,
request: VideoStatusRequest {
//request_id: "ff94f941-62a7-9966-92da-6b84d9eedb50".to_string(),
//request_id: "4eea215a-9d21-9261-be1a-324409ec22c5".to_string(),
request_id: "4d4a6185-209e-95f3-942b-510042637839".to_string(),
},
}).await;
match &result {
Ok(s) => println!(
"status: state={:?} progress={:?} model={:?} video={:?} cost_in_usd_ticks={:?}",
s.state, s.progress, s.model, s.video, s.cost_in_usd_ticks,
),
Ok(r) => println!("status: {:?}", r.status),
Err(e) => println!("status error: {:?}", e),
}
Ok(())
@@ -68,7 +68,7 @@ fn extract_xai_error_message(body: &str) -> Option<String> {
/// Heuristic: does this response body mention content moderation? xAI's
/// safety filter rejections include words like "moderation", "safety",
/// "content_policy", or "blocked".
fn body_indicates_moderation(body: &str) -> bool {
pub(crate) fn body_indicates_moderation(body: &str) -> bool {
let lower = body.to_lowercase();
lower.contains("moderat") // moderation / moderated
|| lower.contains("safety")
@@ -29,19 +29,6 @@ pub enum GrokSpecificApiError {
/// 400 Bad Request — the request body shape was invalid (e.g. missing
/// required field, unsupported model, invalid aspect ratio).
BadRequest(String),
/// The video generation/edit/extension job finished with `status:"failed"`.
///
/// The `code` mirrors xAI's machine-readable error code
/// (`invalid_argument`, `permission_denied`, `failed_precondition`,
/// `internal_error`); `message` is the human-readable description.
VideoJobFailed {
code: String,
message: String,
},
/// The video job expired (poll status was `"expired"`).
VideoJobExpired,
}
impl Error for GrokSpecificApiError {}
@@ -56,10 +43,6 @@ impl Display for GrokSpecificApiError {
Self::RateLimited => write!(f, "Grok API: Rate limited"),
Self::PromptModerated(msg) => write!(f, "Grok API: Prompt moderated: {}", msg),
Self::BadRequest(msg) => write!(f, "Grok API: Bad request: {}", msg),
Self::VideoJobFailed { code, message } => {
write!(f, "Grok API: Video job failed ({}): {}", code, message)
}
Self::VideoJobExpired => write!(f, "Grok API: Video job expired"),
}
}
}
@@ -3,7 +3,7 @@ use std::time::Duration;
use log::{error, info, warn};
use grok_api_client::api::requests::videos::video_status::video_status::{
video_status, VideoStatusArgs, VideoStatusRequest, VideoStatusState, VideoStatusSuccess,
video_status, FailureReason, VideoOutputInfo, VideoStatus, VideoStatusArgs, VideoStatusRequest,
};
use grok_api_client::error::grok_error::GrokError;
use grok_api_client::error::grok_specific_api_error::GrokSpecificApiError;
@@ -66,48 +66,70 @@ async fn poll_one_job(deps: &JobDependencies, job: &PendingGrokApiJob) {
},
}).await;
let success = match poll_result {
Ok(success) => success,
let response = match poll_result {
Ok(response) => response,
Err(err) => {
handle_video_status_error(deps, job, err).await;
return;
}
};
match success.state {
VideoStatusState::Pending => {
let progress = success.progress.map(|p| format!("{}%", p)).unwrap_or_else(|| "?".to_string());
match response.status {
VideoStatus::Pending { progress } => {
let progress_str = progress.map(|p| format!("{}%", p)).unwrap_or_else(|| "?".to_string());
info!(
"Grok request {} for job {} still pending ({} progress).",
job.request_id, job.job_token.as_str(), progress,
job.request_id, job.job_token.as_str(), progress_str,
);
}
VideoStatusState::Done => {
finalize_done_response(deps, job, success).await;
VideoStatus::Complete { video, .. } => {
finalize_complete_response(deps, job, video).await;
}
VideoStatusState::Failed => {
// The Failed variant is mapped into `GrokError::ApiSpecific(VideoJobFailed)`
// by the client. We shouldn't hit this branch, but treat defensively.
warn!(
"Unexpected VideoStatusState::Failed surfaced as Ok for request {} (job {}).",
job.request_id, job.job_token.as_str(),
VideoStatus::Failed { reason, code, error, .. } => {
let reason_str = format_failed_reason(reason, code.as_deref(), error.as_deref());
info!(
"Grok request {} for job {} reported failed: {}.",
job.request_id, job.job_token.as_str(), reason_str,
);
process_failed_job(deps, job, "Grok video status reported failed").await;
process_failed_job(deps, job, &reason_str).await;
let _ = deps.job_stats.increment_failure_count();
}
}
}
async fn finalize_done_response(
fn format_failed_reason(
reason: FailureReason,
maybe_code: Option<&str>,
maybe_error: Option<&str>,
) -> String {
// `process_failed_job` scans this string for "moderation" / "moderated" /
// "platform rules" / "content policy" to decide the user-facing failure
// category, so make sure ContentModerated always lands on that wording.
match reason {
FailureReason::ContentModerated => {
format!(
"Grok video content moderated: {}",
maybe_error.unwrap_or("no details"),
)
}
FailureReason::Unknown => {
let code_part = maybe_code.unwrap_or("unknown");
let error_part = maybe_error.unwrap_or("no details");
format!("Grok video failed ({}): {}", code_part, error_part)
}
}
}
async fn finalize_complete_response(
deps: &JobDependencies,
job: &PendingGrokApiJob,
success: VideoStatusSuccess,
maybe_video: Option<VideoOutputInfo>,
) {
let video_url = match success.video.as_ref().and_then(|v| v.url.clone()) {
let video_url = match maybe_video.as_ref().and_then(|v| v.url.clone()) {
Some(url) => url,
None => {
warn!(
"Grok request {} reported Done with no video.url for job {}. Skipping.",
"Grok request {} reported Complete with no video.url for job {}. Skipping.",
job.request_id, job.job_token.as_str(),
);
return;
@@ -139,28 +161,11 @@ async fn handle_video_status_error(
err: GrokError,
) {
match &err {
// Terminal: mark the job failed.
GrokError::ApiSpecific(GrokSpecificApiError::VideoJobFailed { code, message }) => {
let reason = format!("Grok video failed ({}): {}", code, message);
info!(
"Grok request {} for job {} reported failed. Marking job failed.",
job.request_id, job.job_token.as_str(),
);
process_failed_job(deps, job, &reason).await;
let _ = deps.job_stats.increment_failure_count();
}
GrokError::ApiSpecific(GrokSpecificApiError::VideoJobExpired) => {
let reason = "Grok video job expired";
info!(
"Grok request {} for job {} expired. Marking job failed.",
job.request_id, job.job_token.as_str(),
);
process_failed_job(deps, job, reason).await;
let _ = deps.job_stats.increment_failure_count();
}
GrokError::ApiSpecific(GrokSpecificApiError::NotFound) => {
// Treat NotFound as terminal — xAI doesn't know about this request_id
// anymore (expired retention or never existed).
// anymore (expired retention or never existed). Note: a true
// `status:"expired"` poll response comes back as `VideoStatus::Failed`,
// not as an error, so this arm is only for 404 from the HTTP layer.
let reason = "Grok video job not found (likely expired)";
info!(
"Grok request {} for job {} not found. Marking job failed.",