@@ -19,6 +19,7 @@ import {
1919import { MAX_REMOTE_IMAGE_BYTES } from '@/lib/internal/image/fetch'
2020import { buildImageGenerationRequest } from '@/lib/internal/image/request'
2121import type { ImageGenerationInput , ImageProvider } from '@/lib/internal/image/schema'
22+ import { cancelFalRequest } from '@/lib/media/falai'
2223import { FalAIResultError , fetchFalAIResult } from '@/lib/media/falai-result'
2324import { recordServiceMeteringFailure } from '@/lib/mothership/billing/service-observer'
2425import type { FalAICostMetadata } from '@/lib/tools/falai-pricing'
@@ -465,125 +466,142 @@ async function generateWithFalAI(
465466 buildFalAIQueueUrl ( endpoint , falRequestId , 'response' )
466467 )
467468
468- logger . info ( 'Fal.ai image request created' , { requestId, falRequestId } )
469-
470- const statusValidation = await validateUrlWithDNS ( statusUrl , 'statusUrl' , 'contentFetch' )
471- if ( ! statusValidation . isValid ) {
472- throw new Error ( statusValidation . error )
473- }
469+ const cancelUrl = falQueueUrl (
470+ getStringProperty ( createData , 'cancel_url' ) ||
471+ `${ buildFalAIQueueUrl ( endpoint , falRequestId , 'response' ) } /cancel`
472+ )
474473
475- const pollIntervalMs = 3000
476- const maxAttempts = Math . ceil ( getMaxExecutionTimeout ( ) / pollIntervalMs )
477- let attempts = 0
478-
479- while ( attempts < maxAttempts ) {
480- await interruptibleSleep ( pollIntervalMs , signal )
481- signal ?. throwIfAborted ( )
482-
483- const statusResponse = await secureFetchWithPinnedIP ( statusUrl , statusValidation . resolvedIP , {
484- profile : 'contentFetch' ,
485- maxRedirects : 0 ,
486- headers : {
487- Authorization : `Key ${ apiKey } ` ,
488- } ,
489- maxResponseBytes : MAX_IMAGE_JSON_BYTES ,
490- signal,
491- } )
474+ logger . info ( 'Fal.ai image request created' , { requestId, falRequestId } )
492475
493- if ( ! statusResponse . ok ) {
494- await readResponseTextWithLimit ( statusResponse , {
495- maxBytes : DEFAULT_MAX_ERROR_BODY_BYTES ,
496- label : 'Fal.ai status error response' ,
497- signal,
498- } ) . catch ( ( ) => '' )
499- throw new Error ( `Fal.ai status check failed: ${ statusResponse . status } ` )
476+ let reachedTerminalStatus = false
477+ try {
478+ const statusValidation = await validateUrlWithDNS ( statusUrl , 'statusUrl' , 'contentFetch' )
479+ if ( ! statusValidation . isValid ) {
480+ throw new Error ( statusValidation . error )
500481 }
501482
502- const statusData = await readResponseJsonWithLimit ( statusResponse , {
503- maxBytes : MAX_IMAGE_JSON_BYTES ,
504- label : 'Fal.ai status response' ,
505- signal,
506- } )
507- if ( ! isRecordLike ( statusData ) ) {
508- throw new Error ( 'Invalid Fal.ai status response' )
509- }
483+ const pollIntervalMs = 3000
484+ const maxAttempts = Math . ceil ( getMaxExecutionTimeout ( ) / pollIntervalMs )
485+ let attempts = 0
510486
511- const status = getStringProperty ( statusData , 'status' )
512- if ( status === 'COMPLETED' ) {
513- const { data : resultData , cost : falaiCost } = await fetchFalAIResult ( {
514- apiKey,
515- endpointId : endpoint ,
516- requestId : falRequestId ,
517- queueError : statusData . error ,
487+ while ( attempts < maxAttempts ) {
488+ await interruptibleSleep ( pollIntervalMs , signal )
489+ signal ?. throwIfAborted ( )
490+
491+ const statusResponse = await secureFetchWithPinnedIP ( statusUrl , statusValidation . resolvedIP , {
492+ profile : 'contentFetch' ,
493+ maxRedirects : 0 ,
494+ headers : {
495+ Authorization : `Key ${ apiKey } ` ,
496+ } ,
518497 maxResponseBytes : MAX_IMAGE_JSON_BYTES ,
519- trackCost : body . useHostedCostTracking ,
520- onCost : onFalCost ,
521498 signal,
522- fetchResponse : async ( resultSignal ) => {
523- const resultUrl = falQueueUrl (
524- getStringProperty ( statusData , 'response_url' ) || responseUrl
525- )
526- const resultValidation = await validateUrlWithDNS ( resultUrl , 'resultUrl' , 'contentFetch' )
527- if ( ! resultValidation . isValid ) throw new Error ( resultValidation . error )
528- return secureFetchWithPinnedIP ( resultUrl , resultValidation . resolvedIP , {
529- profile : 'contentFetch' ,
530- maxRedirects : 0 ,
531- headers : { Authorization : `Key ${ apiKey } ` } ,
532- maxResponseBytes : MAX_IMAGE_JSON_BYTES ,
533- signal : resultSignal ,
534- } )
535- } ,
536499 } )
537500
538- if ( ! isRecordLike ( resultData ) ) {
539- throw new Error ( 'Invalid Fal.ai result response' )
501+ if ( ! statusResponse . ok ) {
502+ await readResponseTextWithLimit ( statusResponse , {
503+ maxBytes : DEFAULT_MAX_ERROR_BODY_BYTES ,
504+ label : 'Fal.ai status error response' ,
505+ signal,
506+ } ) . catch ( ( ) => '' )
507+ throw new Error ( `Fal.ai status check failed: ${ statusResponse . status } ` )
508+ }
509+
510+ const statusData = await readResponseJsonWithLimit ( statusResponse , {
511+ maxBytes : MAX_IMAGE_JSON_BYTES ,
512+ label : 'Fal.ai status response' ,
513+ signal,
514+ } )
515+ if ( ! isRecordLike ( statusData ) ) {
516+ throw new Error ( 'Invalid Fal.ai status response' )
540517 }
541518
542- const firstImage =
543- prepared . model . output === 'image' && isRecordLike ( resultData . image )
544- ? resultData . image
545- : firstRecord ( resultData . images )
546- const imageUrl =
547- getStringProperty ( firstImage , 'url' ) ||
548- getStringProperty ( firstImage , 'data' ) ||
549- getStringProperty ( firstImage , 'content' )
550- if ( ! imageUrl ) {
551- throw new Error ( 'No image URL in Fal.ai response' )
519+ const status = getStringProperty ( statusData , 'status' )
520+ if ( status === 'COMPLETED' ) {
521+ reachedTerminalStatus = true
522+ const { data : resultData , cost : falaiCost } = await fetchFalAIResult ( {
523+ apiKey,
524+ endpointId : endpoint ,
525+ requestId : falRequestId ,
526+ queueError : statusData . error ,
527+ maxResponseBytes : MAX_IMAGE_JSON_BYTES ,
528+ trackCost : body . useHostedCostTracking ,
529+ onCost : onFalCost ,
530+ signal,
531+ fetchResponse : async ( resultSignal ) => {
532+ const resultUrl = falQueueUrl (
533+ getStringProperty ( statusData , 'response_url' ) || responseUrl
534+ )
535+ const resultValidation = await validateUrlWithDNS (
536+ resultUrl ,
537+ 'resultUrl' ,
538+ 'contentFetch'
539+ )
540+ if ( ! resultValidation . isValid ) throw new Error ( resultValidation . error )
541+ return secureFetchWithPinnedIP ( resultUrl , resultValidation . resolvedIP , {
542+ profile : 'contentFetch' ,
543+ maxRedirects : 0 ,
544+ headers : { Authorization : `Key ${ apiKey } ` } ,
545+ maxResponseBytes : MAX_IMAGE_JSON_BYTES ,
546+ signal : resultSignal ,
547+ } )
548+ } ,
549+ } )
550+
551+ if ( ! isRecordLike ( resultData ) ) {
552+ throw new Error ( 'Invalid Fal.ai result response' )
553+ }
554+
555+ const firstImage =
556+ prepared . model . output === 'image' && isRecordLike ( resultData . image )
557+ ? resultData . image
558+ : firstRecord ( resultData . images )
559+ const imageUrl =
560+ getStringProperty ( firstImage , 'url' ) ||
561+ getStringProperty ( firstImage , 'data' ) ||
562+ getStringProperty ( firstImage , 'content' )
563+ if ( ! imageUrl ) {
564+ throw new Error ( 'No image URL in Fal.ai response' )
565+ }
566+
567+ const downloaded = await bufferFromImageUrl ( imageUrl , signal )
568+ const contentType =
569+ getStringProperty ( firstImage , 'content_type' ) ||
570+ getStringProperty ( firstImage , 'contentType' ) ||
571+ downloaded . contentType
572+ const fileName =
573+ getStringProperty ( firstImage , 'file_name' ) ||
574+ getStringProperty ( firstImage , 'fileName' ) ||
575+ `falai-${ model } .${ extensionFromContentType ( contentType ) } `
576+
577+ return {
578+ buffer : downloaded . buffer ,
579+ contentType,
580+ fileName,
581+ provider : 'falai' ,
582+ model,
583+ sourceUrl : imageUrl . startsWith ( 'data:' ) ? undefined : imageUrl ,
584+ description : getStringProperty ( resultData , 'description' ) ,
585+ revisedPrompt : getStringProperty ( resultData , 'revised_prompt' ) ,
586+ seed : getNumberProperty ( resultData , 'seed' ) ,
587+ jobId : falRequestId ,
588+ falaiCost,
589+ }
552590 }
553591
554- const downloaded = await bufferFromImageUrl ( imageUrl , signal )
555- const contentType =
556- getStringProperty ( firstImage , 'content_type' ) ||
557- getStringProperty ( firstImage , 'contentType' ) ||
558- downloaded . contentType
559- const fileName =
560- getStringProperty ( firstImage , 'file_name' ) ||
561- getStringProperty ( firstImage , 'fileName' ) ||
562- `falai-${ model } .${ extensionFromContentType ( contentType ) } `
563-
564- return {
565- buffer : downloaded . buffer ,
566- contentType,
567- fileName,
568- provider : 'falai' ,
569- model,
570- sourceUrl : imageUrl . startsWith ( 'data:' ) ? undefined : imageUrl ,
571- description : getStringProperty ( resultData , 'description' ) ,
572- revisedPrompt : getStringProperty ( resultData , 'revised_prompt' ) ,
573- seed : getNumberProperty ( resultData , 'seed' ) ,
574- jobId : falRequestId ,
575- falaiCost,
592+ if ( [ 'ERROR' , 'FAILED' , 'CANCELLED' ] . includes ( status || '' ) ) {
593+ reachedTerminalStatus = true
594+ throw new Error ( `Fal.ai generation failed: ${ getFalAIErrorMessage ( statusData . error ) } ` )
576595 }
577- }
578596
579- if ( [ 'ERROR' , 'FAILED' , 'CANCELLED' ] . includes ( status || '' ) ) {
580- throw new Error ( `Fal.ai generation failed: ${ getFalAIErrorMessage ( statusData . error ) } ` )
597+ attempts += 1
581598 }
582599
583- attempts += 1
600+ throw new Error ( 'Fal.ai image generation timed out' )
601+ } catch ( error ) {
602+ if ( ! reachedTerminalStatus ) await cancelFalRequest ( cancelUrl , apiKey , falRequestId )
603+ throw error
584604 }
585-
586- throw new Error ( 'Fal.ai image generation timed out' )
587605}
588606
589607async function storeGeneratedImage (
0 commit comments