fal webhooks - tested cases for error extraction (#1277)

This commit is contained in:
Brandon Thomas
2026-04-01 02:21:44 -04:00
committed by GitHub
parent b09a0fd854
commit dc30cf9669
14 changed files with 611 additions and 26 deletions
+1
View File
@@ -5,3 +5,4 @@ pub mod error;
pub mod model;
pub mod requests;
pub mod utils;
pub mod webhook_api;
@@ -0,0 +1,3 @@
pub mod parse_webhook_inner_payload;
pub mod parse_webhook_payload;
pub mod payload;
@@ -0,0 +1,240 @@
use serde_json::Value;
use crate::webhook_api::payload::webhook_error_type::WebhookErrorType;
use crate::webhook_api::payload::webhook_inner_payload::{ErrorData, PayloadErrorData, SuccessData, WebhookInnerPayload};
use crate::webhook_api::payload::webhook_payload::{WebhookPayload, WebhookStatus};
/// Parse the inner payload of a FAL webhook into one of three cases.
///
/// 1. If status is ERROR, parse out the first `detail` entry's `msg` and `type`.
/// 2. If status is OK but there's no payload and there is a `payload_error`, return PayloadError.
/// 3. Otherwise, return Success with the payload.
pub fn parse_webhook_inner_payload(webhook: &WebhookPayload) -> WebhookInnerPayload {
match webhook.status {
WebhookStatus::Error => {
let (message, error_type) = extract_error_first_detail(&webhook.payload);
WebhookInnerPayload::Error(ErrorData {
message,
error_type,
})
}
WebhookStatus::Ok => {
// Check for payload_error case: status=OK but no payload, has payload_error.
if webhook.payload.is_none() {
if let Some(ref payload_error) = webhook.payload_error {
return WebhookInnerPayload::PayloadError(PayloadErrorData {
payload_error: payload_error.clone(),
});
}
}
WebhookInnerPayload::Success(SuccessData {
payload: webhook.payload.clone().unwrap_or(Value::Null),
})
}
}
}
/// Extract the first `msg` and `type` from `payload.detail[]`.
fn extract_error_first_detail(payload: &Option<Value>) -> (Option<String>, Option<WebhookErrorType>) {
let payload = match payload {
Some(p) => p,
None => return (None, None),
};
let detail_array = match payload.get("detail").and_then(|v| v.as_array()) {
Some(arr) => arr,
None => return (None, None),
};
let first = match detail_array.first() {
Some(item) => item,
None => return (None, None),
};
let message = first.get("msg").and_then(|v| v.as_str()).map(|s| s.to_string());
let error_type = first
.get("type")
.and_then(|v| v.as_str())
.map(WebhookErrorType::from_str);
(message, error_type)
}
#[cfg(test)]
mod tests {
use super::*;
fn load_test_webhook(filename: &str) -> WebhookPayload {
let path = format!("test_data/webhooks/{}", filename);
let json = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("Failed to read {}: {}", path, e));
serde_json::from_str(&json)
.unwrap_or_else(|e| panic!("Failed to parse {}: {}", path, e))
}
#[test]
fn gpt_image_1p5_content_policy_violation() {
let webhook = load_test_webhook("gpt_image_1p5_fail_content_policy.json");
let result = parse_webhook_inner_payload(&webhook);
match result {
WebhookInnerPayload::Error(data) => {
assert_eq!(
data.error_type,
Some(WebhookErrorType::ContentPolicyViolation),
);
assert_eq!(
data.message.as_deref(),
Some("The content could not be processed because it contained material flagged by a content checker."),
);
}
other => panic!("Expected WebhookInnerPayload::Error, got {:?}", other),
}
}
#[test]
fn gpt_image_invalid_api_key() {
let webhook = load_test_webhook("gpt_image_fail_invalid_api_key.json");
let result = parse_webhook_inner_payload(&webhook);
match result {
WebhookInnerPayload::Error(data) => {
assert_eq!(
data.error_type,
Some(WebhookErrorType::InvalidApiKey),
);
assert_eq!(
data.message.as_deref(),
Some("Invalid API key"),
);
}
other => panic!("Expected WebhookInnerPayload::Error, got {:?}", other),
}
}
#[test]
fn kling_1p6_pro_file_too_large() {
let webhook = load_test_webhook("kling_1p6_pro_file_too_large_error.json");
let result = parse_webhook_inner_payload(&webhook);
match result {
WebhookInnerPayload::Error(data) => {
assert_eq!(
data.error_type,
Some(WebhookErrorType::FileTooLarge),
);
assert_eq!(
data.message.as_deref(),
Some("File size exceeds the maximum allowed size of 10485760 bytes. Please upload a smaller file."),
);
}
other => panic!("Expected WebhookInnerPayload::Error, got {:?}", other),
}
}
#[test]
fn kling_3p0_pro_content_policy_violation() {
let webhook = load_test_webhook("kling_3p0_pro_fail_content_policy.json");
let result = parse_webhook_inner_payload(&webhook);
match result {
WebhookInnerPayload::Error(data) => {
assert_eq!(
data.error_type,
Some(WebhookErrorType::ContentPolicyViolation),
);
assert_eq!(
data.message.as_deref(),
Some("The content could not be processed because it contained material flagged by a content checker."),
);
}
other => panic!("Expected WebhookInnerPayload::Error, got {:?}", other),
}
}
#[test]
fn nano_banana_pro_no_media_generated() {
let webhook = load_test_webhook("nano_banana_pro_fail_no_media_generated.json");
let result = parse_webhook_inner_payload(&webhook);
match result {
WebhookInnerPayload::Error(data) => {
assert_eq!(
data.error_type,
Some(WebhookErrorType::NoMediaGenerated),
);
assert!(
data.message.as_deref().unwrap().starts_with("The model did not generate"),
);
}
other => panic!("Expected WebhookInnerPayload::Error, got {:?}", other),
}
}
#[test]
fn payload_error_case() {
let webhook = WebhookPayload {
request_id: "test-123".to_string(),
gateway_request_id: "test-123".to_string(),
status: WebhookStatus::Ok,
error: None,
payload: None,
payload_error: Some("encoding error occurred".to_string()),
};
let result = parse_webhook_inner_payload(&webhook);
match result {
WebhookInnerPayload::PayloadError(data) => {
assert_eq!(data.payload_error, "encoding error occurred");
}
other => panic!("Expected WebhookInnerPayload::PayloadError, got {:?}", other),
}
}
#[test]
fn success_case() {
let webhook = WebhookPayload {
request_id: "test-456".to_string(),
gateway_request_id: "test-456".to_string(),
status: WebhookStatus::Ok,
error: None,
payload: Some(serde_json::json!({"images": [{"url": "https://example.com/img.png"}]})),
payload_error: None,
};
let result = parse_webhook_inner_payload(&webhook);
match result {
WebhookInnerPayload::Success(data) => {
assert!(data.payload.get("images").is_some());
}
other => panic!("Expected WebhookInnerPayload::Success, got {:?}", other),
}
}
#[test]
fn error_with_no_payload() {
let webhook = WebhookPayload {
request_id: "test-789".to_string(),
gateway_request_id: "test-789".to_string(),
status: WebhookStatus::Error,
error: Some("Internal server error".to_string()),
payload: None,
payload_error: None,
};
let result = parse_webhook_inner_payload(&webhook);
match result {
WebhookInnerPayload::Error(data) => {
assert_eq!(data.message, None);
assert_eq!(data.error_type, None);
}
other => panic!("Expected WebhookInnerPayload::Error, got {:?}", other),
}
}
}
@@ -0,0 +1,5 @@
use crate::webhook_api::payload::webhook_payload::WebhookPayload;
pub fn parse_webhook_payload(json: &str) -> Result<WebhookPayload, serde_json::Error> {
serde_json::from_str(json)
}
@@ -0,0 +1,3 @@
pub mod webhook_inner_payload;
pub mod webhook_payload;
pub mod webhook_error_type;
@@ -0,0 +1,104 @@
use serde::Deserialize;
/// Machine-readable error type from FAL webhook error payloads.
///
/// These are the `type` field values from the `detail` array in FAL error responses.
/// See: https://fal.ai/docs/documentation/model-apis/errors
#[derive(Clone, Debug, Eq, PartialEq, Hash, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WebhookErrorType {
FileTooLarge,
InternalServerError,
GenerationTimeout,
DownstreamServiceError,
DownstreamServiceUnavailable,
ContentPolicyViolation,
NoMediaGenerated,
ImageTooSmall,
ImageTooLarge,
ImageLoadError,
FileDownloadError,
FaceDetectionError,
GreaterThan,
GreaterThanEqual,
LessThan,
LessThanEqual,
MultipleOf,
SequenceTooShort,
SequenceTooLong,
OneOf,
FeatureNotSupported,
InvalidArchive,
ArchiveFileCountBelowMinimum,
ArchiveFileCountExceedsMaximum,
InvalidApiKey,
/// Catch-all for unrecognized error types from FAL.
#[serde(untagged)]
Unknown(String),
}
impl WebhookErrorType {
pub fn from_str(value: &str) -> Self {
serde_json::from_value(serde_json::Value::String(value.to_string()))
.unwrap_or_else(|_| Self::Unknown(value.to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deserialize_known_types() {
let cases = vec![
("\"file_too_large\"", WebhookErrorType::FileTooLarge),
("\"internal_server_error\"", WebhookErrorType::InternalServerError),
("\"generation_timeout\"", WebhookErrorType::GenerationTimeout),
("\"downstream_service_error\"", WebhookErrorType::DownstreamServiceError),
("\"downstream_service_unavailable\"", WebhookErrorType::DownstreamServiceUnavailable),
("\"content_policy_violation\"", WebhookErrorType::ContentPolicyViolation),
("\"no_media_generated\"", WebhookErrorType::NoMediaGenerated),
("\"image_too_small\"", WebhookErrorType::ImageTooSmall),
("\"image_too_large\"", WebhookErrorType::ImageTooLarge),
("\"image_load_error\"", WebhookErrorType::ImageLoadError),
("\"file_download_error\"", WebhookErrorType::FileDownloadError),
("\"face_detection_error\"", WebhookErrorType::FaceDetectionError),
("\"greater_than\"", WebhookErrorType::GreaterThan),
("\"greater_than_equal\"", WebhookErrorType::GreaterThanEqual),
("\"less_than\"", WebhookErrorType::LessThan),
("\"less_than_equal\"", WebhookErrorType::LessThanEqual),
("\"multiple_of\"", WebhookErrorType::MultipleOf),
("\"sequence_too_short\"", WebhookErrorType::SequenceTooShort),
("\"sequence_too_long\"", WebhookErrorType::SequenceTooLong),
("\"one_of\"", WebhookErrorType::OneOf),
("\"feature_not_supported\"", WebhookErrorType::FeatureNotSupported),
("\"invalid_archive\"", WebhookErrorType::InvalidArchive),
("\"archive_file_count_below_minimum\"", WebhookErrorType::ArchiveFileCountBelowMinimum),
("\"archive_file_count_exceeds_maximum\"", WebhookErrorType::ArchiveFileCountExceedsMaximum),
("\"invalid_api_key\"", WebhookErrorType::InvalidApiKey),
];
for (json, expected) in cases {
let deserialized: WebhookErrorType = serde_json::from_str(json).unwrap();
assert_eq!(deserialized, expected, "failed for {}", json);
}
}
#[test]
fn deserialize_unknown_type() {
let deserialized: WebhookErrorType = serde_json::from_str("\"some_new_error_type\"").unwrap();
assert_eq!(deserialized, WebhookErrorType::Unknown("some_new_error_type".to_string()));
}
#[test]
fn from_str_known() {
assert_eq!(WebhookErrorType::from_str("content_policy_violation"), WebhookErrorType::ContentPolicyViolation);
assert_eq!(WebhookErrorType::from_str("face_detection_error"), WebhookErrorType::FaceDetectionError);
}
#[test]
fn from_str_unknown() {
assert_eq!(WebhookErrorType::from_str("brand_new_type"), WebhookErrorType::Unknown("brand_new_type".to_string()));
}
}
@@ -0,0 +1,32 @@
use serde_json::Value;
use crate::webhook_api::payload::webhook_error_type::WebhookErrorType;
/// The parsed inner payload of a FAL webhook.
#[derive(Debug)]
pub enum WebhookInnerPayload {
/// The webhook reported success and has a payload.
Success(SuccessData),
/// The webhook reported an error (status=ERROR) with optional detail info.
Error(ErrorData),
/// The webhook reported success but had no payload and instead had a payload_error.
PayloadError(PayloadErrorData),
}
#[derive(Debug)]
pub struct SuccessData {
pub payload: Value,
}
#[derive(Debug)]
pub struct ErrorData {
/// The first human-readable message from `payload.detail[].msg`, if any.
pub message: Option<String>,
/// The first machine-readable error type from `payload.detail[].type`, if any.
pub error_type: Option<WebhookErrorType>,
}
#[derive(Debug)]
pub struct PayloadErrorData {
pub payload_error: String,
}
@@ -0,0 +1,43 @@
use serde::Deserialize;
use serde_json::Value;
/// The value FAL sends to webhook endpoints.
#[derive(Deserialize, Debug, Clone)]
pub struct WebhookPayload {
/// Value originally used in the queue API
pub request_id: String,
/// Mostly the same as `request_id`, but if the request failed and was retried,
/// then gateway_request_id will have the value of the last tried request,
/// while request_id will be the value used in the queue API.
pub gateway_request_id: String,
/// Status of the request: OK or ERROR.
pub status: WebhookStatus,
/// When an error happens, the status will be ERROR.
/// The error property will contain a message and payload will contain the error details.
/// eg. ""Invalid status code: 422"
pub error: Option<String>,
/// Payload of the webhook (typically present)
/// THIS FIELD IS HIGHLY VARIABLE JSON.
/// This is present for both *success* and *failure* cases,
/// though the payload differs. Sometimes it is absent.
pub payload: Option<Value>,
/// In rare instances, if there was an error encoding the payload,
/// this may be set. This can seemingly be set on "OK" responses.
pub payload_error: Option<String>,
}
#[derive(Deserialize, Debug, Clone, Copy)]
pub enum WebhookStatus {
/// Success case
#[serde(alias = "OK")]
Ok,
/// Failure case
#[serde(alias = "ERROR")]
Error
}
@@ -0,0 +1,29 @@
{
"error": "Unexpected status code: 422",
"gateway_request_id": "019d4756-130b-7141-a16c-8f411b785844",
"payload": {
"detail": [
{
"input": {
"background": "auto",
"image_size": "1536x1024",
"input_fidelity": null,
"num_images": 1,
"openai_api_key": null,
"output_format": "png",
"prompt": "[redacted nsfw query]",
"quality": "medium"
},
"loc": [
"body",
"prompt"
],
"msg": "The content could not be processed because it contained material flagged by a content checker.",
"type": "content_policy_violation",
"url": "https://docs.fal.ai/errors#content_policy_violation"
}
]
},
"request_id": "019d4756-130b-7141-a16c-8f411b785844",
"status": "ERROR"
}
@@ -0,0 +1,29 @@
{
"error": "Unexpected status code: 422",
"gateway_request_id": "019d4771-5b51-7480-afc6-67e6e74b9f22",
"payload": {
"detail": [
{
"input": {
"background": "auto",
"image_size": "1536x1024",
"input_fidelity": null,
"num_images": 1,
"openai_api_key": "REDACTED",
"output_format": "png",
"prompt": "[redacted]",
"quality": "high"
},
"loc": [
"body",
"openai_api_key"
],
"msg": "Invalid API key",
"type": "invalid_api_key",
"url": "https://docs.fal.ai/errors#invalid_api_key"
}
]
},
"request_id": "019d4771-5b51-7480-afc6-67e6e74b9f22",
"status": "ERROR"
}
@@ -0,0 +1,23 @@
{
"error": "Unexpected status code: 422",
"gateway_request_id": "019d477e-c123-7120-81c7-30d6b58a6720",
"payload": {
"detail": [
{
"ctx": {
"max_size": 10485760
},
"input": "[redacted]",
"loc": [
"body",
"image_url"
],
"msg": "File size exceeds the maximum allowed size of 10485760 bytes. Please upload a smaller file.",
"type": "file_too_large",
"url": "https://docs.fal.ai/errors#file_too_large"
}
]
},
"request_id": "019d477e-c123-7120-81c7-30d6b58a6720",
"status": "ERROR"
}
@@ -0,0 +1,29 @@
{
"error": "Unexpected status code: 422",
"gateway_request_id": "019d4761-035d-7b50-8c42-1521efdd4f34",
"payload": {
"detail": [
{
"input": {
"aspect_ratio": "16:9",
"cfg_scale": 0.5,
"duration": "3",
"generate_audio": false,
"multi_prompt": null,
"negative_prompt": "blur, distort, and low quality",
"prompt": "[redacted prompt]",
"shot_type": "customize",
"voice_ids": null
},
"loc": [
"body"
],
"msg": "The content could not be processed because it contained material flagged by a content checker.",
"type": "content_policy_violation",
"url": "https://docs.fal.ai/errors#content_policy_violation"
}
]
},
"request_id": "019d4761-035d-7b50-8c42-1521efdd4f34",
"status": "ERROR"
}
@@ -0,0 +1,30 @@
{
"error": "Unexpected status code: 422",
"gateway_request_id": "019d475a-291a-7681-81dc-b27d2489beae",
"payload": {
"detail": [
{
"input": {
"aspect_ratio": "16:9",
"enable_web_search": false,
"limit_generations": false,
"num_images": 1,
"output_format": "png",
"prompt": "[nsfw prompt redacted]",
"resolution": "1K",
"safety_tolerance": "4",
"seed": null,
"sync_mode": false
},
"loc": [
"body"
],
"msg": "The model did not generate the expected output for this prompt. This may occur for several reasons, including unsafe content, a prompt that is incompatible with the selected media type (e.g., prompting an image model to generate audio), references to missing attachments, or other cases where the input cannot be processed as the requested output type. Please review your inputs and try again. If the issue persists, contact support.",
"type": "no_media_generated",
"url": "https://docs.fal.ai/errors#no_media_generated"
}
]
},
"request_id": "019d475a-291a-7681-81dc-b27d2489beae",
"status": "ERROR"
}
@@ -6,9 +6,11 @@ use crate::http_server::endpoints::webhooks::process_success::handle_successful_
use crate::state::server_state::ServerState;
use actix_web::error::ResponseError;
use actix_web::http::StatusCode;
use actix_web::web::Bytes;
use actix_web::web::Json;
use actix_web::{web, HttpRequest, HttpResponse};
use anyhow::Error;
use fal_client::webhook_api::payload::webhook_payload::{WebhookPayload, WebhookStatus};
use http_server_common::response::response_success_helpers::SimpleGenericJsonSuccess;
use http_server_common::response::serialize_as_json_error::serialize_as_json_error;
use log::{error, info, warn};
@@ -80,54 +82,66 @@ impl From<anyhow::Error> for FalWebhookError {
// =============== Handler ===============
/// Fal webhook
#[utoipa::path(
post,
tag = "Webhooks",
path = "/v1/webhooks/fal",
responses(
(status = 200, description = "Success", body = SimpleGenericJsonSuccess),
(status = 400, description = "Bad input", body = FalWebhookError),
(status = 401, description = "Not authorized", body = FalWebhookError),
(status = 500, description = "Server error", body = FalWebhookError),
),
params(
("request" = FalWebhookRequest, description = "Payload for Request"),
)
)]
// /// Fal webhook
// #[utoipa::path(
// post,
// tag = "Webhooks",
// path = "/v1/webhooks/fal",
// responses(
// (status = 200, description = "Success", body = SimpleGenericJsonSuccess),
// (status = 400, description = "Bad input", body = FalWebhookError),
// (status = 401, description = "Not authorized", body = FalWebhookError),
// (status = 500, description = "Server error", body = FalWebhookError),
// ),
// params(
// ("request" = FalWebhookRequest, description = "Payload for Request"),
// )
// )]
pub async fn fal_webhook_handler(
http_request: HttpRequest,
request_body_bytes: Bytes,
server_state: web::Data<Arc<ServerState>>,
request: Json<FalWebhookRequest>,
//request: Json<FalWebhookRequest>,
) -> Result<Json<SimpleGenericJsonSuccess>, FalWebhookError> {
info!("Received FAL webhook body: {:?}", request);
let webhook_payload = String::from_utf8(request_body_bytes.to_vec())
.map_err(|err| {
error!("Fal Webhook: could not decode request body to UTF-8: {:?}", err);
FalWebhookError::BadInput("Could not decode request body to UTF-8".to_string())
})?;
let request_id = request.request_id
.as_deref()
.ok_or_else(|| FalWebhookError::BadInput("Missing request_id".to_string()))?;
info!("Received FAL webhook body: {}", webhook_payload);
info!("FAL webhook request_id: {} (status: {:?})", request_id, request.status);
let webhook_payload = WebhookPayload::try_from_json(&webhook_payload)
.map_err(|err| {
error!("Fal Webhook: could not parse webhook payload as JSON: {:?}", err);
FalWebhookError::BadInput("Could not parse webhook payload as JSON".to_string())
})?;
let request_id = webhook_payload.request_id.as_str();
let status = webhook_payload.status;
info!("FAL webhook request_id: {} (status: {:?})", request_id, status);
// TODO(bt): Longer term, we should just use `fal_client` to parse webhooks and add lots of integration tests
// across dozens of real messages.
let payload = request.payload
let payload = webhook_payload.payload
.as_ref()
.ok_or_else(|| {
warn!("FAL webhook missing payload for request_id: {}", request_id);
FalWebhookError::BadInput("Missing payload".to_string())
})?;
let result = match request.status {
FalWebhookStatus::Ok => {
let result = match status {
WebhookStatus::Ok => {
handle_sucessful_fal_webhook(&server_state, request_id, payload).await
}
FalWebhookStatus::Error => {
WebhookStatus::Error => {
handle_failed_fal_webhook(
&server_state,
request_id,
payload,
request.error.as_deref(),
webhook_payload.error.as_deref(),
).await
}
};