Compare commits

...
Author SHA1 Message Date
Brandon Thomas 9da8764803 misc fix 2026-05-08 20:00:45 -04:00
Brandon Thomas a0edec29e7 adjust error handling 2026-05-08 19:44:04 -04:00
Brandon Thomas da7082f405 error for bad webhooks 2026-05-08 19:32:33 -04:00
Brandon Thomas 33f1829c07 config 2026-05-08 19:29:09 -04:00
Brandon Thomas 5f9941a26a setup webhook 2026-05-08 19:23:58 -04:00
Brandon Thomas 24d49a2263 webhook handler 2026-05-08 19:17:54 -04:00
Brandon Thomas 961862cdff rename endpoint 2026-05-08 18:09:49 -04:00
Brandon Thomas 9c7b35671b test data 2026-05-08 17:39:52 -04:00
35 changed files with 945 additions and 32 deletions
@@ -0,0 +1,135 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n jobs.token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,\n\n jobs.status as `status: enums::common::job_status_plus::JobStatusPlus`,\n\n jobs.maybe_external_third_party as `external_third_party: enums::by_table::generic_inference_jobs::inference_job_external_third_party::InferenceJobExternalThirdParty`,\n jobs.maybe_external_third_party_id as `external_third_party_id`,\n\n jobs.maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n jobs.maybe_creator_anonymous_visitor_token as `maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken`,\n jobs.creator_ip_address,\n\n jobs.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,\n jobs.maybe_debug_log_event_token as `maybe_debug_log_event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken`,\n\n jobs.created_at,\n jobs.updated_at\n\nFROM generic_inference_jobs as jobs\n\nWHERE jobs.maybe_external_third_party = ?\nAND jobs.maybe_external_third_party_id = ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "status: enums::common::job_status_plus::JobStatusPlus",
"type_info": {
"type": "String",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | ENUM",
"char_set": 224,
"max_size": 76
}
},
{
"ordinal": 2,
"name": "external_third_party: enums::by_table::generic_inference_jobs::inference_job_external_third_party::InferenceJobExternalThirdParty",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 3,
"name": "external_third_party_id",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 256
}
},
{
"ordinal": 4,
"name": "maybe_creator_user_token: tokens::tokens::users::UserToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 5,
"name": "maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 6,
"name": "creator_ip_address",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 160
}
},
{
"ordinal": 7,
"name": "maybe_prompt_token: tokens::tokens::prompts::PromptToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 8,
"name": "maybe_debug_log_event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 9,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 10,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP | ON_UPDATE_NOW",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 2
},
"nullable": [
false,
false,
true,
true,
true,
true,
false,
true,
true,
false,
false
]
},
"hash": "31d85cc0013f29e6db2d272ee6e709cca79b46496a2c222960f1a1419175f00b"
}
@@ -0,0 +1,3 @@
$kind: collection
name: edit
order: 312
@@ -0,0 +1,23 @@
$kind: http-request
name: Beeble SwitchX Edit Video
url: "{{api_host}}/v1/generate/video/edit/beeble_switchx"
method: POST
headers:
- key: Accept
value: application/json
- key: Session
value: ""
disabled: true
body:
type: json
content: >-
{
"uuid_idempotency_token": "{{$randomUUID}}",
//"source_video_media_token": "m_6vve5gffn2r4rajyvy8z2380x15kqc", // two corgis (dev)
"source_video_media_token": "m_rkgdk7qw0a7gz4mrzfv0q78ax57kbx", // corgi cowboy (dev)
//"source_video_media_token": "m_1k19k6dgdny253zag8g3jqf3bgemss", // corgi watermellon (prod)
"reference_image_media_token": "m_pf310vdhc4v98n7r8mz70bz9geest3" // tornado (dev)
//"reference_image_media_token": "m_z4y097t9pdmbyatf70z0qb8zvfphvm" // cactus (prod)
//"prompt": "Stormy skies, there's a rotating tornado in the background"
}
order: 1778262498846
@@ -9,7 +9,7 @@ body:
// hanashi : password (dev)
//"username_or_email": "hanashi",
//"password": "hanashi"
"username_or_email": "newuser2",
"password": "newuser2"
"username_or_email": "newuser4",
"password": "newuser4"
}
order: 1000
@@ -18,6 +18,12 @@ pub enum BeebleSpecificApiError {
/// The generation job failed with a reported error message.
GenerationFailed(String),
/// 400 Bad Request — the callback_url is not a valid publicly-reachable HTTPS URL.
BadWebhookUrl {
message: String,
webhook_url: String,
},
}
impl Error for BeebleSpecificApiError {}
@@ -30,6 +36,9 @@ impl Display for BeebleSpecificApiError {
Self::IdempotencyConflict => write!(f, "Beeble API: Idempotency conflict"),
Self::RateLimited => write!(f, "Beeble API: Rate limited"),
Self::GenerationFailed(msg) => write!(f, "Beeble API: Generation failed: {}", msg),
Self::BadWebhookUrl { message, webhook_url } => {
write!(f, "Beeble API: Bad webhook URL '{}': {}", webhook_url, message)
}
}
}
}
@@ -5,3 +5,4 @@ pub(crate) mod test_utils;
pub mod creds;
pub mod error;
pub mod requests;
pub mod webhook_api;
@@ -69,7 +69,12 @@ mod tests {
use super::*;
use crate::test_utils::get_test_api_key::get_test_api_key;
const TEST_JOB_ID: &str = "swx_uZAcwMjRZpDesqfp3pKkF";
//const TEST_JOB_ID: &str = "swx_uZAcwMjRZpDesqfp3pKkF"; // Production
//const TEST_JOB_ID: &str = "swx_Ontb6KfbfdY24Cz8DZ9c8"; // Dev
//const TEST_JOB_ID: &str = "swx_k5SmgdFcesuE05BE8VqFb"; // Dev
//const TEST_JOB_ID: &str = "swx_snkg7Et8EOvWaQ0QgoZlZ"; // Dev
const TEST_JOB_ID: &str = "swx_a1GBhEHwIfhzXfWEkEbcb"; // Dev
//const TEST_JOB_ID: &str = "swx_9Cbr7coiW9ZOayIrKGe0v"; // Prod
#[tokio::test]
#[ignore] // manually test — requires real API key
@@ -0,0 +1,47 @@
use reqwest::StatusCode;
use crate::error::beeble_error::BeebleError;
use crate::error::beeble_generic_api_error::BeebleGenericApiError;
use crate::error::beeble_specific_api_error::BeebleSpecificApiError;
/// Additional context for error handling that depends on the original request.
pub struct ErrorContext <'a> {
pub maybe_callback_url: Option<&'a str>,
}
/// Classify a non-success Beeble API response into the appropriate error.
///
/// Only call this when `!status.is_success()`. Always returns `Err`.
pub fn handle_error_response<T>(
status: StatusCode,
response_body: &str,
context: &ErrorContext,
) -> Result<T, BeebleError> {
match status.as_u16() {
401 => Err(BeebleSpecificApiError::Unauthorized.into()),
402 => Err(BeebleSpecificApiError::InsufficientCredits.into()),
400 if response_body.contains("INVALID_CALLBACK_URL") => {
let message = extract_error_message(response_body)
.unwrap_or_else(|| "callback_url must be a valid, publicly-reachable HTTPS URL".to_string());
Err(BeebleSpecificApiError::BadWebhookUrl {
message,
webhook_url: context.maybe_callback_url.unwrap_or_default().to_string(),
}.into())
}
409 => Err(BeebleSpecificApiError::IdempotencyConflict.into()),
429 => Err(BeebleSpecificApiError::RateLimited.into()),
_ => {
Err(BeebleGenericApiError::UncategorizedBadResponseWithStatusAndBody {
status_code: status,
body: response_body.to_string(),
}.into())
}
}
}
/// Try to extract the "message" field from a Beeble error response body.
/// Beeble errors look like: `{"error":{"message":"...","code":"..."}}`
fn extract_error_message(body: &str) -> Option<String> {
let parsed: serde_json::Value = serde_json::from_str(body).ok()?;
parsed.get("error")?.get("message")?.as_str().map(|s| s.to_string())
}
@@ -1,2 +1,3 @@
pub mod start_generation;
pub(crate) mod handle_error_response;
pub(crate) mod request_types;
pub mod start_generation;
@@ -4,7 +4,7 @@ use crate::creds::beeble_api_key::BeebleApiKey;
use crate::error::beeble_client_error::BeebleClientError;
use crate::error::beeble_error::BeebleError;
use crate::error::beeble_generic_api_error::BeebleGenericApiError;
use crate::error::beeble_specific_api_error::BeebleSpecificApiError;
use crate::requests::start_generation::handle_error_response::{handle_error_response, ErrorContext};
use crate::requests::start_generation::request_types::*;
const BEEBLE_API_BASE_URL: &str = "https://api.beeble.ai/v1";
@@ -110,7 +110,7 @@ pub async fn start_generation(args: StartGenerationArgs) -> Result<StartGenerati
reference_image_uri: req.reference_image_uri,
alpha_uri: req.alpha_uri,
max_resolution: req.max_resolution,
callback_url: req.callback_url,
callback_url: req.callback_url.clone(),
idempotency_key: req.idempotency_key,
};
@@ -127,24 +127,17 @@ pub async fn start_generation(args: StartGenerationArgs) -> Result<StartGenerati
.map_err(|err| BeebleGenericApiError::ReqwestError(err))?;
let status = response.status();
let response_body = response.text()
.await
.map_err(|err| BeebleGenericApiError::ReqwestError(err))?;
info!("Beeble start generation response: status={}", status);
match status.as_u16() {
401 => return Err(BeebleSpecificApiError::Unauthorized.into()),
402 => return Err(BeebleSpecificApiError::InsufficientCredits.into()),
409 => return Err(BeebleSpecificApiError::IdempotencyConflict.into()),
429 => return Err(BeebleSpecificApiError::RateLimited.into()),
_ if !status.is_success() => {
return Err(BeebleGenericApiError::UncategorizedBadResponseWithStatusAndBody {
status_code: status,
body: response_body,
}.into());
}
_ => {}
if !status.is_success() {
return handle_error_response(status, &response_body, &ErrorContext {
maybe_callback_url: req.callback_url.as_deref(),
});
}
let parsed: GenerationJobResponseBody = serde_json::from_str(&response_body)
@@ -0,0 +1,115 @@
use serde_derive::Deserialize;
/// Raw deserialized webhook payload from Beeble.
#[derive(Deserialize, Debug, Clone)]
pub struct BeebleWebhookPayload {
pub id: String,
pub status: BeebleWebhookStatus,
pub output: Option<BeebleWebhookOutput>,
pub error: Option<String>,
pub completed_at: Option<String>,
}
#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum BeebleWebhookStatus {
Completed,
Failed,
/// Catch unknown variants gracefully.
#[serde(other)]
Unknown,
}
#[derive(Deserialize, Debug, Clone)]
pub struct BeebleWebhookOutput {
pub render: Option<String>,
pub source: Option<String>,
pub alpha: Option<String>,
}
pub fn parse_beeble_webhook(raw_body: &str) -> Result<BeebleWebhookPayload, serde_json::Error> {
serde_json::from_str(raw_body)
}
#[cfg(test)]
mod tests {
use super::*;
const SUCCESS_PAYLOAD: &str = r#"{
"id": "swx_abc123",
"status": "completed",
"output": {
"render": "https://cdn.beeble.ai/render.mp4",
"source": "https://cdn.beeble.ai/source.mp4",
"alpha": "https://cdn.beeble.ai/alpha.mp4"
},
"completed_at": "2026-02-23T10:05:00Z"
}"#;
const FAILURE_PAYLOAD: &str = r#"{
"id": "swx_abc123",
"status": "failed",
"completed_at": "2026-02-23T10:05:00Z",
"error": "Processing failed"
}"#;
const UNKNOWN_STATUS_PAYLOAD: &str = r#"{
"id": "swx_xyz789",
"status": "processing",
"completed_at": null
}"#;
const NULL_OUTPUT_PAYLOAD: &str = r#"{
"id": "swx_abc123",
"status": "completed",
"output": null,
"completed_at": "2026-02-23T10:05:00Z"
}"#;
mod parse_tests {
use super::*;
#[test]
fn parse_success_payload() {
let payload = parse_beeble_webhook(SUCCESS_PAYLOAD).unwrap();
assert_eq!(payload.id, "swx_abc123");
assert_eq!(payload.status, BeebleWebhookStatus::Completed);
assert!(payload.error.is_none());
let output = payload.output.unwrap();
assert_eq!(output.render.as_deref(), Some("https://cdn.beeble.ai/render.mp4"));
assert_eq!(output.source.as_deref(), Some("https://cdn.beeble.ai/source.mp4"));
assert_eq!(output.alpha.as_deref(), Some("https://cdn.beeble.ai/alpha.mp4"));
}
#[test]
fn parse_failure_payload() {
let payload = parse_beeble_webhook(FAILURE_PAYLOAD).unwrap();
assert_eq!(payload.id, "swx_abc123");
assert_eq!(payload.status, BeebleWebhookStatus::Failed);
assert_eq!(payload.error.as_deref(), Some("Processing failed"));
assert!(payload.output.is_none());
}
#[test]
fn parse_unknown_status() {
let payload = parse_beeble_webhook(UNKNOWN_STATUS_PAYLOAD).unwrap();
assert_eq!(payload.id, "swx_xyz789");
assert_eq!(payload.status, BeebleWebhookStatus::Unknown);
}
#[test]
fn parse_null_output() {
let payload = parse_beeble_webhook(NULL_OUTPUT_PAYLOAD).unwrap();
assert_eq!(payload.id, "swx_abc123");
assert_eq!(payload.status, BeebleWebhookStatus::Completed);
assert!(payload.output.is_none());
}
#[test]
fn parse_invalid_json() {
let result = parse_beeble_webhook("not valid json");
assert!(result.is_err());
}
}
}
@@ -0,0 +1 @@
pub mod beeble_webhook_payload;
@@ -0,0 +1,134 @@
use anyhow::anyhow;
use chrono::{DateTime, Utc};
use log::warn;
use sqlx::pool::PoolConnection;
use sqlx::{MySql, MySqlPool};
use enums::by_table::generic_inference_jobs::inference_job_external_third_party::InferenceJobExternalThirdParty;
use enums::common::job_status_plus::JobStatusPlus;
use errors::AnyhowResult;
use tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken;
use tokens::tokens::prompts::PromptToken;
use tokens::tokens::users::UserToken;
#[derive(Debug, Default)]
pub struct BeebleJobDetails {
pub job_token: InferenceJobToken,
pub status: JobStatusPlus,
pub external_third_party: InferenceJobExternalThirdParty,
pub external_third_party_id: String,
pub maybe_creator_user_token: Option<UserToken>,
pub maybe_creator_anonymous_visitor_token: Option<AnonymousVisitorTrackingToken>,
pub creator_ip_address: String,
pub maybe_prompt_token: Option<PromptToken>,
pub maybe_debug_log_event_token: Option<DebugLogEventToken>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Default)]
struct RawJobRecord {
job_token: InferenceJobToken,
status: JobStatusPlus,
// NB: Nullable, but required to be filled for this query
external_third_party: Option<InferenceJobExternalThirdParty>,
// NB: Nullable, but required to be filled for this query
external_third_party_id: Option<String>,
maybe_creator_user_token: Option<UserToken>,
maybe_creator_anonymous_visitor_token: Option<AnonymousVisitorTrackingToken>,
creator_ip_address: String,
maybe_prompt_token: Option<PromptToken>,
maybe_debug_log_event_token: Option<DebugLogEventToken>,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
/// Returns Ok(None) when the record cannot be found.
pub async fn get_inference_job_by_beeble_id(beeble_id: &str, mysql_pool: &MySqlPool)
-> AnyhowResult<Option<BeebleJobDetails>>
{
let mut connection = mysql_pool.acquire().await?;
get_inference_job_by_beeble_id_from_connection(beeble_id, &mut connection).await
}
/// Returns Ok(None) when the record cannot be found.
pub async fn get_inference_job_by_beeble_id_from_connection(beeble_id: &str, mysql_connection: &mut PoolConnection<MySql>)
-> AnyhowResult<Option<BeebleJobDetails>>
{
let maybe_status = sqlx::query_as!(
RawJobRecord,
r#"
SELECT
jobs.token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,
jobs.status as `status: enums::common::job_status_plus::JobStatusPlus`,
jobs.maybe_external_third_party as `external_third_party: enums::by_table::generic_inference_jobs::inference_job_external_third_party::InferenceJobExternalThirdParty`,
jobs.maybe_external_third_party_id as `external_third_party_id`,
jobs.maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,
jobs.maybe_creator_anonymous_visitor_token as `maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken`,
jobs.creator_ip_address,
jobs.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,
jobs.maybe_debug_log_event_token as `maybe_debug_log_event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken`,
jobs.created_at,
jobs.updated_at
FROM generic_inference_jobs as jobs
WHERE jobs.maybe_external_third_party = ?
AND jobs.maybe_external_third_party_id = ?
"#,
InferenceJobExternalThirdParty::Beeble.to_str(),
beeble_id
)
.fetch_one(&mut **mysql_connection)
.await;
let record = match maybe_status {
Ok(record) => record,
Err(err) => match err {
sqlx::Error::RowNotFound => return Ok(None),
_ => {
warn!("error querying job record: {:?}", err);
return Err(anyhow!("error querying job record: {:?}", err));
}
}
};
let record = raw_record_to_public_result(record)?;
Ok(Some(record))
}
fn raw_record_to_public_result(record: RawJobRecord) -> AnyhowResult<BeebleJobDetails> {
Ok(BeebleJobDetails {
job_token: record.job_token,
status: record.status,
external_third_party: record.external_third_party.ok_or_else(|| anyhow!("missing external_third_party"))?,
external_third_party_id: record.external_third_party_id.ok_or_else(|| anyhow!("missing external_third_party_id"))?,
maybe_creator_user_token: record.maybe_creator_user_token,
maybe_creator_anonymous_visitor_token: record.maybe_creator_anonymous_visitor_token,
creator_ip_address: record.creator_ip_address,
maybe_prompt_token: record.maybe_prompt_token,
maybe_debug_log_event_token: record.maybe_debug_log_event_token,
created_at: record.created_at,
updated_at: record.updated_at,
})
}
@@ -1 +1,2 @@
pub mod get_inference_job_by_beeble_id;
pub mod insert_generic_inference_job_for_beeble_queue_with_apriori_job_token;
@@ -22,6 +22,7 @@ pub enum DebugLogType {
FalRequest,
KinoviRequest,
FalWebhook,
BeebleWebhook,
}
impl_enum_display_and_debug_using_to_str!(DebugLogType);
@@ -35,6 +36,7 @@ impl DebugLogType {
Self::FalRequest => "fal_request",
Self::KinoviRequest => "kinovi_request",
Self::FalWebhook => "fal_webhook",
Self::BeebleWebhook => "beeble_webhook",
}
}
@@ -44,6 +46,7 @@ impl DebugLogType {
"fal_request" => Ok(Self::FalRequest),
"kinovi_request" => Ok(Self::KinoviRequest),
"fal_webhook" => Ok(Self::FalWebhook),
"beeble_webhook" => Ok(Self::BeebleWebhook),
_ => Err(EnumError::CouldNotConvertFromString(value.to_string())),
}
}
@@ -54,6 +57,7 @@ impl DebugLogType {
Self::FalRequest,
Self::KinoviRequest,
Self::FalWebhook,
Self::BeebleWebhook,
])
}
}
@@ -74,6 +78,7 @@ mod tests {
assert_serialization(DebugLogType::FalRequest, "fal_request");
assert_serialization(DebugLogType::KinoviRequest, "kinovi_request");
assert_serialization(DebugLogType::FalWebhook, "fal_webhook");
assert_serialization(DebugLogType::BeebleWebhook, "beeble_webhook");
}
#[test]
@@ -82,6 +87,7 @@ mod tests {
assert_eq!(DebugLogType::FalRequest.to_str(), "fal_request");
assert_eq!(DebugLogType::KinoviRequest.to_str(), "kinovi_request");
assert_eq!(DebugLogType::FalWebhook.to_str(), "fal_webhook");
assert_eq!(DebugLogType::BeebleWebhook.to_str(), "beeble_webhook");
}
#[test]
@@ -90,6 +96,7 @@ mod tests {
assert_eq!(DebugLogType::from_str("fal_request").unwrap(), DebugLogType::FalRequest);
assert_eq!(DebugLogType::from_str("kinovi_request").unwrap(), DebugLogType::KinoviRequest);
assert_eq!(DebugLogType::from_str("fal_webhook").unwrap(), DebugLogType::FalWebhook);
assert_eq!(DebugLogType::from_str("beeble_webhook").unwrap(), DebugLogType::BeebleWebhook);
}
#[test]
@@ -106,11 +113,12 @@ mod tests {
#[test]
fn all_variants() {
let mut variants = DebugLogType::all_variants();
assert_eq!(variants.len(), 4);
assert_eq!(variants.len(), 5);
assert_eq!(variants.pop_first(), Some(DebugLogType::HttpRequest));
assert_eq!(variants.pop_first(), Some(DebugLogType::FalRequest));
assert_eq!(variants.pop_first(), Some(DebugLogType::KinoviRequest));
assert_eq!(variants.pop_first(), Some(DebugLogType::FalWebhook));
assert_eq!(variants.pop_first(), Some(DebugLogType::BeebleWebhook));
assert_eq!(variants.pop_first(), None);
}
}
@@ -51,6 +51,8 @@ FAL_WEBHOOK_URL="https://brandons-macbook-pro.taild62114.ts.net/v1/webhooks/fal"
#FAL_WEBHOOK_URL="https://halide.tailce84f.ts.net/v1/webhooks/fal"
#FAL_WEBHOOK_URL="https://s1.pancake-chimaera.ts.net/api/fal/webhook"
BEEBLE_WEBHOOK_URL="https://brandons-macbook-pro.taild62114.ts.net/v1/webhooks/beeble"
STRIPE_ARTCRAFT_CHECKOUT_SUCCESS_URL="https://getartcraft.com/checkout_success"
STRIPE_ARTCRAFT_CHECKOUT_CANCEL_URL="https://getartcraft.com/checkout_cancel"
STRIPE_ARTCRAFT_PORTAL_RETURN_URL="https://getartcraft.com/portal_closed"
@@ -214,7 +214,7 @@ pub async fn beeble_switchx_edit_video_gen_handler(
reference_image_uri: maybe_reference_beeble_uri,
alpha_uri: None,
max_resolution: Some(1080),
callback_url: None,
callback_url: Some(server_state.beeble.webhook_url.clone()),
idempotency_key: Some(request.uuid_idempotency_token.clone()),
},
}).await.map_err(|err| {
@@ -0,0 +1,425 @@
use std::io::Write;
use std::sync::Arc;
use actix_web::web::Bytes;
use actix_web::web::Json;
use actix_web::{web, HttpRequest};
use log::{error, info, warn};
use sqlx::pool::PoolConnection;
use sqlx::MySql;
use tempfile::NamedTempFile;
use beeble_client::webhook_api::beeble_webhook_payload::{
parse_beeble_webhook, BeebleWebhookPayload, BeebleWebhookStatus,
};
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
use enums::by_table::debug_logs::debug_log_type::DebugLogType;
use enums::by_table::generic_inference_jobs::frontend_failure_category::FrontendFailureCategory;
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory;
use enums::by_table::media_files::media_file_type::MediaFileType;
use ffmpeg_utils::ffprobe::ffprobe_get_info::ffprobe_get_info;
use filesys::path_to_string::path_to_string;
use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes;
use http_server_common::response::response_success_helpers::SimpleGenericJsonSuccess;
use mysql_queries::queries::debug_logs::insert_debug_log::{insert_debug_log, InsertDebugLogArgs};
use mysql_queries::queries::generic_inference::beeble::get_inference_job_by_beeble_id::{
get_inference_job_by_beeble_id_from_connection, BeebleJobDetails,
};
use mysql_queries::queries::generic_inference::fal::mark_fal_generic_inference_job_successfully_done::{
mark_fal_generic_inference_job_successfully_done, MarkJobArgs,
};
use mysql_queries::queries::generic_inference::job::mark_job_failed_by_token::{
mark_job_failed_by_token_from_connection, MarkJobFailedByTokenFromConnectionArgs,
};
use mysql_queries::queries::media_files::create::insert_builder::media_file_insert_builder::MediaFileInsertBuilder;
use pager::notification::notification_details_builder::NotificationDetailsBuilder;
use pager::notification::notification_urgency::NotificationUrgency;
use thumbnail_generator::task_client::thumbnail_task::{ThumbnailTaskBuilder, ThumbnailTaskInputMimeType};
use tokens::tokens::media_files::MediaFileToken;
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::http_server::endpoints::webhooks::fal::process_success::resolve_file_metadata::resolve_file_metadata;
use crate::state::server_state::ServerState;
use crate::util::http_download_url_to_bytes::http_download_url_to_bytes;
const VIDEO_BUCKET_PREFIX: Option<&str> = Some("artcraft_");
// =============== Handler ===============
pub async fn beeble_webhook_handler(
http_request: HttpRequest,
request_body_bytes: Bytes,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<SimpleGenericJsonSuccess>, AdvancedCommonWebError> {
// Step 1: Parse bytes into a UTF-8 string and log it.
let raw_body = String::from_utf8(request_body_bytes.to_vec())
.map_err(|err| {
error!("Beeble webhook: could not decode request body to UTF-8: {:?}", err);
enqueue_parse_error_alert(&server_state, &http_request, "UTF-8 decode failed", &err, None);
AdvancedCommonWebError::from_error(err)
})?;
info!("Received Beeble webhook body: {}", raw_body);
// Step 2: Parse into BeebleWebhookPayload.
let payload = parse_beeble_webhook(&raw_body)
.map_err(|err| {
error!("Beeble webhook: could not parse webhook payload: {:?}", err);
enqueue_parse_error_alert(&server_state, &http_request, "JSON parse failed", &err, Some(&raw_body));
AdvancedCommonWebError::from_error(err)
})?;
let job_id = payload.id.as_str();
info!("Beeble webhook job_id: {} (status: {:?})", job_id, payload.status);
// Step 3: Acquire a MySQL connection.
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
// Step 4: Look up the job by external third-party ID.
let job = match get_inference_job_by_beeble_id_from_connection(job_id, &mut mysql_connection).await {
Ok(Some(record)) => record,
Ok(None) => {
warn!("Could not find job record by Beeble job_id: {}", job_id);
return Err(AdvancedCommonWebError::NotFound);
}
Err(err) => {
error!("Error querying job record for Beeble job_id {}: {:?}", job_id, err);
return Err(AdvancedCommonWebError::from_anyhow_error(err));
}
};
info!("Beeble webhook job record for job_id {}: {:?}", job_id, job);
// Insert debug log for the webhook payload.
if let Some(debug_log_event_token) = &job.maybe_debug_log_event_token {
if let Err(err) = insert_debug_log(InsertDebugLogArgs {
apriori_debug_log_event_token: Some(debug_log_event_token),
maybe_creator_user_token: job.maybe_creator_user_token.as_ref(),
debug_log_type: DebugLogType::BeebleWebhook,
message: &raw_body,
mysql_executor: &mut *mysql_connection,
phantom: Default::default(),
}).await {
warn!("Failed to insert Beeble webhook debug log: {:?}", err);
}
}
// Step 5: Branch on status.
let result = match payload.status {
BeebleWebhookStatus::Completed => {
handle_completed(&server_state, &mut mysql_connection, job_id, &payload, &job).await
}
BeebleWebhookStatus::Failed => {
handle_failed(&mut mysql_connection, job_id, &payload, &job).await
}
BeebleWebhookStatus::Unknown => {
warn!("Beeble webhook received unknown status for job_id {}", job_id);
Err(AdvancedCommonWebError::server_error_with_message(
&format!("Beeble webhook received unknown status for job_id {}", job_id),
))
}
};
if let Err(ref err) = result {
if err.is_server_error() {
error!("Beeble webhook error for job_id {}: {:?}", job_id, err);
let description = format!(
"Beeble webhook failed for job_id: {}\n\nError: {:?}\n\nWebhook JSON Payload: {}",
job_id, err, raw_body,
);
let mut builder = if let Some(cause_arc) = err.clone_cause_arc() {
NotificationDetailsBuilder::from_error(cause_arc)
.set_title(format!("Beeble webhook processing failed (job_id: {})", job_id))
} else {
NotificationDetailsBuilder::from_title(
format!("Beeble webhook processing failed (job_id: {})", job_id)
)
};
builder = builder
.set_description(Some(description))
.set_third_party_id(Some(job_id.to_string()))
.set_urgency(Some(NotificationUrgency::High))
.set_http_method(Some(http_request.method().to_string()))
.set_http_path(Some(http_request.path().to_string()));
if let Err(pager_err) = server_state.pager.enqueue_page(builder.build()) {
error!("Failed to enqueue Beeble webhook pager alert: {:?}", pager_err);
}
}
}
result
}
// =============== Private helpers ===============
async fn handle_completed(
server_state: &ServerState,
mysql_connection: &mut PoolConnection<MySql>,
job_id: &str,
payload: &BeebleWebhookPayload,
job: &BeebleJobDetails,
) -> Result<Json<SimpleGenericJsonSuccess>, AdvancedCommonWebError> {
let output = payload.output.as_ref().ok_or_else(|| {
warn!("Beeble webhook completed but no output for job_id {}", job_id);
AdvancedCommonWebError::server_error_with_message("Beeble webhook completed but output is missing")
})?;
let render_url = output.render.as_deref().ok_or_else(|| {
warn!("Beeble webhook completed but no render URL for job_id {}", job_id);
AdvancedCommonWebError::server_error_with_message("Beeble webhook completed but render URL is missing")
})?;
info!("Downloading Beeble render video from {} for job_id {}", render_url, job_id);
// Download and process the primary render video.
let render_media_token = download_and_upload_video(render_url, job, server_state).await?;
info!("Render video uploaded with token {} for job_id {}", render_media_token, job_id);
// Download and process the alpha video if present.
if let Some(ref alpha_url) = output.alpha {
info!("Downloading Beeble alpha video from {} for job_id {}", alpha_url, job_id);
match download_and_upload_video(alpha_url, job, server_state).await {
Ok(alpha_token) => {
info!("Alpha video uploaded with token {} for job_id {}", alpha_token, job_id);
}
Err(err) => {
// Fail open for alpha — the render is the primary artifact.
warn!("Failed to process alpha video for job_id {}: {:?}", job_id, err);
}
}
}
// Mark the job as successfully done with the render video's media token.
mark_fal_generic_inference_job_successfully_done(MarkJobArgs {
job_token: &job.job_token,
media_file_token: &render_media_token,
maybe_batch_token: None,
mysql_executor: &mut **mysql_connection,
phantom: Default::default(),
}).await.map_err(|err| {
warn!("Error marking job as successfully done for job_id {}: {:?}", job_id, err);
AdvancedCommonWebError::from_anyhow_error(err)
})?;
info!("Job {} marked as successfully done for Beeble job_id {}", job.job_token, job_id);
Ok(SimpleGenericJsonSuccess::wrapped(true))
}
async fn handle_failed(
mysql_connection: &mut PoolConnection<MySql>,
job_id: &str,
payload: &BeebleWebhookPayload,
job: &BeebleJobDetails,
) -> Result<Json<SimpleGenericJsonSuccess>, AdvancedCommonWebError> {
let public_failure_reason = payload.error
.as_deref()
.unwrap_or("Unknown Beeble error")
.to_string();
let internal_failure_reason = format!(
"Beeble job_id={}, error={:?}",
job_id,
payload.error,
);
info!(
"Marking job {} as failed for Beeble job_id {}. Reason: {}",
job.job_token.as_str(),
job_id,
public_failure_reason,
);
if let Err(err) = mark_job_failed_by_token_from_connection(MarkJobFailedByTokenFromConnectionArgs {
mysql_connection,
job_token: &job.job_token,
maybe_public_failure_reason: Some(&public_failure_reason),
internal_debugging_failure_reason: &internal_failure_reason,
maybe_frontend_failure_category: Some(FrontendFailureCategory::GenerationFailed),
}).await {
error!(
"Error marking job {} as failed for Beeble job_id {}: {:?}",
job.job_token.as_str(),
job_id,
err,
);
return Err(AdvancedCommonWebError::from_anyhow_error(err));
}
info!(
"Job {} marked as failed for Beeble job_id {}.",
job.job_token.as_str(),
job_id,
);
Ok(SimpleGenericJsonSuccess::wrapped(true))
}
async fn download_and_upload_video(
video_url: &str,
job: &BeebleJobDetails,
server_state: &ServerState,
) -> Result<MediaFileToken, AdvancedCommonWebError> {
// Download with a retry if the first attempt returns suspiciously few bytes.
let mut file_bytes = http_download_url_to_bytes(video_url)
.await
.map_err(|err| {
warn!("Failed to download video from {}: {:?}", video_url, err);
AdvancedCommonWebError::from_error(err)
})?;
if file_bytes.len() <= 10 {
warn!(
"Downloaded only {} bytes from {} — retrying once",
file_bytes.len(),
video_url,
);
file_bytes = http_download_url_to_bytes(video_url)
.await
.map_err(|err| {
warn!("Failed to download video on retry from {}: {:?}", video_url, err);
AdvancedCommonWebError::from_error(err)
})?;
}
// Resolve mime type from magic bytes, with no fallback content_type from Beeble.
let metadata = resolve_file_metadata(&file_bytes, None)
.ok_or_else(|| {
warn!(
"Could not determine file type for Beeble video (bytes: {})",
file_bytes.len(),
);
AdvancedCommonWebError::server_error_with_message(
&format!("Could not determine file type for Beeble video (bytes: {})", file_bytes.len()))
})?;
info!("File type: {}, extension: {:?}, source: {:?}",
metadata.mime_type, metadata.file_extension, metadata.source);
let mime_type = metadata.mime_type.as_str();
let media_file_type = MediaFileType::try_from_mime_type(mime_type)
.ok_or_else(|| {
warn!("Unsupported media file type: {}", mime_type);
AdvancedCommonWebError::server_error_with_message(
&format!("Unsupported media file type: {}", mime_type))
})?;
let extension_with_period = metadata.file_extension.extension_with_period();
let file_size_bytes = file_bytes.len();
let file_hash = sha256_hash_bytes(&file_bytes)
.map_err(|e| {
warn!("Failed to hash video bytes: {:?}", e);
AdvancedCommonWebError::from_anyhow_error(e)
})?;
let mut maybe_duration_millis = None;
let mut maybe_frame_width = None;
let mut maybe_frame_height = None;
if let Ok(mut file) = NamedTempFile::new() {
let _r = file.write_all(&file_bytes);
if let Ok(video_info) = ffprobe_get_info(&file.path()) {
maybe_duration_millis = video_info.duration
.as_ref()
.map(|duration| duration.millis as u64);
maybe_frame_width = video_info.dimensions
.as_ref()
.map(|dims| dims.width as u32);
maybe_frame_height = video_info.dimensions
.as_ref()
.map(|dims| dims.height as u32);
}
}
let public_upload_path = MediaFileBucketPath::generate_new(VIDEO_BUCKET_PREFIX, Some(&extension_with_period));
info!("Uploading media to bucket path: {}", public_upload_path.get_full_object_path_str());
server_state.public_bucket_client.upload_file_with_content_type_process(
public_upload_path.get_full_object_path_str(),
file_bytes.as_ref(),
mime_type)
.await
.map_err(|e| {
warn!("Failed to upload video to bucket: {:?}", e);
AdvancedCommonWebError::from_anyhow_error(e)
})?;
let media_token = MediaFileInsertBuilder::new()
.maybe_creator_user(job.maybe_creator_user_token.as_ref())
.maybe_creator_anonymous_visitor(job.maybe_creator_anonymous_visitor_token.as_ref())
.creator_ip_address(&job.creator_ip_address)
.public_bucket_directory_hash(&public_upload_path)
.media_file_class(MediaFileClass::Video)
.media_file_type(media_file_type)
.media_file_origin_category(MediaFileOriginCategory::Inference)
.mime_type(mime_type)
.file_size_bytes(file_size_bytes as u64)
.maybe_frame_width(maybe_frame_width)
.maybe_frame_height(maybe_frame_height)
.maybe_duration_millis(maybe_duration_millis)
.checksum_sha2(&file_hash)
.maybe_prompt_token(job.maybe_prompt_token.as_ref())
.insert_pool(&server_state.mysql_pool)
.await
.map_err(|e| {
warn!("Failed to insert video media file record: {:?}", e);
AdvancedCommonWebError::from_error(e)
})?;
info!("Video media file uploaded with token: {}", media_token);
// let thumbnail_task_result =
// ThumbnailTaskBuilder::new_for_source_mimetype(ThumbnailTaskInputMimeType::MP4)
// .with_bucket(server_state.public_bucket_client.bucket_name().as_str())
// .with_path(&*path_to_string(public_upload_path.to_full_object_pathbuf()))
// .with_output_suffix("thumb")
// .with_event_id(&media_token.to_string())
// .send_all()
// .await;
// if let Err(err) = thumbnail_task_result {
// // Fail open
// error!("Failed to create some/all thumbnail tasks: {:?}", err);
// }
Ok(media_token)
}
/// Send a pager alert for early parse failures (before we have a job_id).
fn enqueue_parse_error_alert<E: std::fmt::Debug>(
server_state: &ServerState,
http_request: &HttpRequest,
context: &str,
err: &E,
maybe_raw_body: Option<&str>,
) {
let description = match maybe_raw_body {
Some(body) => format!("Error: {:?}\n\nBeeble Webhook JSON Payload: {}", err, body),
None => format!("Beeble Error: {:?}", err),
};
let notification = NotificationDetailsBuilder::from_title(
format!("Beeble webhook parse failure: {}", context))
.set_description(Some(description))
.set_urgency(Some(NotificationUrgency::High))
.set_http_method(Some(http_request.method().to_string()))
.set_http_path(Some(http_request.path().to_string()))
.build();
if let Err(pager_err) = server_state.pager.enqueue_page(notification) {
error!("Failed to enqueue Beeble webhook parse error alert: {:?}", pager_err);
}
}
@@ -0,0 +1 @@
pub mod beeble_webhook_handler;
@@ -1,8 +1,8 @@
use std::sync::Arc;
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::http_server::endpoints::webhooks::process_failure::handle_failed_fal_webhook::handle_failed_fal_webhook;
use crate::http_server::endpoints::webhooks::process_success::handle_successful_fal_webhook::handle_successful_fal_webhook;
use crate::http_server::endpoints::webhooks::fal::process_failure::handle_failed_fal_webhook::handle_failed_fal_webhook;
use crate::http_server::endpoints::webhooks::fal::process_success::handle_successful_fal_webhook::handle_successful_fal_webhook;
use crate::state::server_state::ServerState;
use actix_web::web::Bytes;
use actix_web::web::Json;
@@ -0,0 +1,3 @@
pub mod fal_webhook_handler;
pub(super) mod process_failure;
pub(super) mod process_success;
@@ -4,4 +4,4 @@ mod process_images_payload;
mod process_model_glb_payload;
mod process_model_mesh_payload;
mod process_video_payload;
mod resolve_file_metadata;
pub(in crate::http_server::endpoints::webhooks) mod resolve_file_metadata;
@@ -1,5 +1,5 @@
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::http_server::endpoints::webhooks::process_success::resolve_file_metadata::resolve_file_metadata;
use crate::http_server::endpoints::webhooks::fal::process_success::resolve_file_metadata::resolve_file_metadata;
use crate::state::server_state::ServerState;
use crate::util::http_download_url_to_bytes::http_download_url_to_bytes;
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
@@ -1,5 +1,5 @@
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::http_server::endpoints::webhooks::process_success::resolve_file_metadata::resolve_file_metadata;
use crate::http_server::endpoints::webhooks::fal::process_success::resolve_file_metadata::resolve_file_metadata;
use crate::state::server_state::ServerState;
use crate::util::http_download_url_to_bytes::http_download_url_to_bytes;
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
@@ -1,5 +1,5 @@
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::http_server::endpoints::webhooks::process_success::resolve_file_metadata::resolve_file_metadata;
use crate::http_server::endpoints::webhooks::fal::process_success::resolve_file_metadata::resolve_file_metadata;
use crate::state::server_state::ServerState;
use crate::util::http_download_url_to_bytes::http_download_url_to_bytes;
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
@@ -1,5 +1,5 @@
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::http_server::endpoints::webhooks::process_success::resolve_file_metadata::resolve_file_metadata;
use crate::http_server::endpoints::webhooks::fal::process_success::resolve_file_metadata::resolve_file_metadata;
use crate::state::server_state::ServerState;
use crate::util::http_download_url_to_bytes::http_download_url_to_bytes;
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
@@ -1,5 +1,5 @@
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::http_server::endpoints::webhooks::process_success::resolve_file_metadata::resolve_file_metadata;
use crate::http_server::endpoints::webhooks::fal::process_success::resolve_file_metadata::resolve_file_metadata;
use crate::state::server_state::ServerState;
use crate::util::http_download_url_to_bytes::http_download_url_to_bytes;
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
@@ -1,3 +1,2 @@
pub mod fal_webhook_handler;
pub (super) mod process_failure;
pub (super) mod process_success;
pub mod beeble;
pub mod fal;
@@ -1,4 +1,5 @@
use crate::http_server::endpoints::webhooks::fal_webhook_handler::fal_webhook_handler;
use crate::http_server::endpoints::webhooks::beeble::beeble_webhook_handler::beeble_webhook_handler;
use crate::http_server::endpoints::webhooks::fal::fal_webhook_handler::fal_webhook_handler;
use actix_http::body::MessageBody;
use actix_service::ServiceFactory;
use actix_web::dev::{ServiceRequest, ServiceResponse};
@@ -20,5 +21,9 @@ where
.route(web::post().to(fal_webhook_handler))
.route(web::head().to(|| HttpResponse::Ok()))
)
.service(web::resource("/beeble")
.route(web::post().to(beeble_webhook_handler))
.route(web::head().to(|| HttpResponse::Ok()))
)
)
}
@@ -479,6 +479,7 @@ async fn main() -> AnyhowResult<()> {
api_key: beeble_client::creds::beeble_api_key::BeebleApiKey::new(
easyenv::get_env_string_required("BEEBLE_API_KEY")?,
),
webhook_url: easyenv::get_env_string_required("BEEBLE_WEBHOOK_URL")?,
},
fal: FalData {
api_key: fal_api_key,
@@ -328,6 +328,7 @@ pub struct FalData {
#[derive(Clone)]
pub struct BeebleData {
pub api_key: BeebleApiKey,
pub webhook_url: String,
}
/// Seedance 2 Pro integration