@@ -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'
@@ -456,134 +457,149 @@ async function generateWithFalAI(
456457 throw new Error ( 'Fal.ai queue response missing request_id' )
457458 }
458459
459- const statusUrl = falQueueUrl (
460- getStringProperty ( createData , 'status_url' ) ||
461- buildFalAIQueueUrl ( endpoint , falRequestId , 'status' )
462- )
463- const responseUrl = falQueueUrl (
464- getStringProperty ( createData , 'response_url' ) ||
465- buildFalAIQueueUrl ( endpoint , falRequestId , 'response' )
466- )
460+ /** Built from the endpoint, never a provider URL, so a rejected one can still be cancelled. */
461+ const cancelUrl = `${ buildFalAIQueueUrl ( endpoint , falRequestId , 'response' ) } /cancel`
467462
468463 logger . info ( 'Fal.ai image request created' , { requestId, falRequestId } )
469464
470- const statusValidation = await validateUrlWithDNS ( statusUrl , 'statusUrl' , 'contentFetch' )
471- if ( ! statusValidation . isValid ) {
472- throw new Error ( statusValidation . error )
473- }
474-
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- } )
465+ let reachedTerminalStatus = false
466+ try {
467+ const statusUrl = falQueueUrl (
468+ getStringProperty ( createData , 'status_url' ) ||
469+ buildFalAIQueueUrl ( endpoint , falRequestId , 'status' )
470+ )
471+ const responseUrl = falQueueUrl (
472+ getStringProperty ( createData , 'response_url' ) ||
473+ buildFalAIQueueUrl ( endpoint , falRequestId , 'response' )
474+ )
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+ const statusValidation = await validateUrlWithDNS ( statusUrl , 'statusUrl' , 'contentFetch' )
477+ if ( ! statusValidation . isValid ) {
478+ throw new Error ( statusValidation . error )
500479 }
501480
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- }
481+ const pollIntervalMs = 3000
482+ const maxAttempts = Math . ceil ( getMaxExecutionTimeout ( ) / pollIntervalMs )
483+ let attempts = 0
510484
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 ,
485+ while ( attempts < maxAttempts ) {
486+ await interruptibleSleep ( pollIntervalMs , signal )
487+ signal ?. throwIfAborted ( )
488+
489+ const statusResponse = await secureFetchWithPinnedIP ( statusUrl , statusValidation . resolvedIP , {
490+ profile : 'contentFetch' ,
491+ maxRedirects : 0 ,
492+ headers : {
493+ Authorization : `Key ${ apiKey } ` ,
494+ } ,
518495 maxResponseBytes : MAX_IMAGE_JSON_BYTES ,
519- trackCost : body . useHostedCostTracking ,
520- onCost : onFalCost ,
521496 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- } ,
536497 } )
537498
538- if ( ! isRecordLike ( resultData ) ) {
539- throw new Error ( 'Invalid Fal.ai result response' )
499+ if ( ! statusResponse . ok ) {
500+ await readResponseTextWithLimit ( statusResponse , {
501+ maxBytes : DEFAULT_MAX_ERROR_BODY_BYTES ,
502+ label : 'Fal.ai status error response' ,
503+ signal,
504+ } ) . catch ( ( ) => '' )
505+ throw new Error ( `Fal.ai status check failed: ${ statusResponse . status } ` )
540506 }
541507
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' )
508+ const statusData = await readResponseJsonWithLimit ( statusResponse , {
509+ maxBytes : MAX_IMAGE_JSON_BYTES ,
510+ label : 'Fal.ai status response' ,
511+ signal,
512+ } )
513+ if ( ! isRecordLike ( statusData ) ) {
514+ throw new Error ( 'Invalid Fal.ai status response' )
552515 }
553516
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,
517+ const status = getStringProperty ( statusData , 'status' )
518+ if ( status === 'COMPLETED' ) {
519+ reachedTerminalStatus = true
520+ const { data : resultData , cost : falaiCost } = await fetchFalAIResult ( {
521+ apiKey,
522+ endpointId : endpoint ,
523+ requestId : falRequestId ,
524+ queueError : statusData . error ,
525+ maxResponseBytes : MAX_IMAGE_JSON_BYTES ,
526+ trackCost : body . useHostedCostTracking ,
527+ onCost : onFalCost ,
528+ signal,
529+ fetchResponse : async ( resultSignal ) => {
530+ const resultUrl = falQueueUrl (
531+ getStringProperty ( statusData , 'response_url' ) || responseUrl
532+ )
533+ const resultValidation = await validateUrlWithDNS (
534+ resultUrl ,
535+ 'resultUrl' ,
536+ 'contentFetch'
537+ )
538+ if ( ! resultValidation . isValid ) throw new Error ( resultValidation . error )
539+ return secureFetchWithPinnedIP ( resultUrl , resultValidation . resolvedIP , {
540+ profile : 'contentFetch' ,
541+ maxRedirects : 0 ,
542+ headers : { Authorization : `Key ${ apiKey } ` } ,
543+ maxResponseBytes : MAX_IMAGE_JSON_BYTES ,
544+ signal : resultSignal ,
545+ } )
546+ } ,
547+ } )
548+
549+ if ( ! isRecordLike ( resultData ) ) {
550+ throw new Error ( 'Invalid Fal.ai result response' )
551+ }
552+
553+ const firstImage =
554+ prepared . model . output === 'image' && isRecordLike ( resultData . image )
555+ ? resultData . image
556+ : firstRecord ( resultData . images )
557+ const imageUrl =
558+ getStringProperty ( firstImage , 'url' ) ||
559+ getStringProperty ( firstImage , 'data' ) ||
560+ getStringProperty ( firstImage , 'content' )
561+ if ( ! imageUrl ) {
562+ throw new Error ( 'No image URL in Fal.ai response' )
563+ }
564+
565+ const downloaded = await bufferFromImageUrl ( imageUrl , signal )
566+ const contentType =
567+ getStringProperty ( firstImage , 'content_type' ) ||
568+ getStringProperty ( firstImage , 'contentType' ) ||
569+ downloaded . contentType
570+ const fileName =
571+ getStringProperty ( firstImage , 'file_name' ) ||
572+ getStringProperty ( firstImage , 'fileName' ) ||
573+ `falai-${ model } .${ extensionFromContentType ( contentType ) } `
574+
575+ return {
576+ buffer : downloaded . buffer ,
577+ contentType,
578+ fileName,
579+ provider : 'falai' ,
580+ model,
581+ sourceUrl : imageUrl . startsWith ( 'data:' ) ? undefined : imageUrl ,
582+ description : getStringProperty ( resultData , 'description' ) ,
583+ revisedPrompt : getStringProperty ( resultData , 'revised_prompt' ) ,
584+ seed : getNumberProperty ( resultData , 'seed' ) ,
585+ jobId : falRequestId ,
586+ falaiCost,
587+ }
576588 }
577- }
578589
579- if ( [ 'ERROR' , 'FAILED' , 'CANCELLED' ] . includes ( status || '' ) ) {
580- throw new Error ( `Fal.ai generation failed: ${ getFalAIErrorMessage ( statusData . error ) } ` )
590+ if ( [ 'ERROR' , 'FAILED' , 'CANCELLED' ] . includes ( status || '' ) ) {
591+ reachedTerminalStatus = true
592+ throw new Error ( `Fal.ai generation failed: ${ getFalAIErrorMessage ( statusData . error ) } ` )
593+ }
594+
595+ attempts += 1
581596 }
582597
583- attempts += 1
598+ throw new Error ( 'Fal.ai image generation timed out' )
599+ } catch ( error ) {
600+ if ( ! reachedTerminalStatus ) await cancelFalRequest ( cancelUrl , apiKey , falRequestId )
601+ throw error
584602 }
585-
586- throw new Error ( 'Fal.ai image generation timed out' )
587603}
588604
589605async function storeGeneratedImage (
0 commit comments