Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/fix-fal-failed-status.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@tanstack/ai-fal': patch
'@tanstack/ai': patch
---

fix: handle errors from fal result fetch on completed jobs

fal.ai does not return a FAILED queue status — invalid jobs report COMPLETED, and the real error (e.g. 422 validation) only surfaces when fetching results. `getVideoUrl()` now catches these errors and extracts detailed validation messages. `getVideoJobStatus()` returns `status: 'failed'` when the result fetch throws on a "completed" job.
2 changes: 1 addition & 1 deletion packages/typescript/ai-fal/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
"video-generation"
],
"dependencies": {
"@fal-ai/client": "^1.9.1"
"@fal-ai/client": "^1.9.4"
},
"devDependencies": {
"@tanstack/ai": "workspace:*",
Expand Down
27 changes: 24 additions & 3 deletions packages/typescript/ai-fal/src/adapters/video.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ interface FalVideoResultData {

/**
* Maps fal.ai queue status to TanStack AI video status.
*
* Note: fal.ai does not return a FAILED queue status. Errors surface
* as exceptions when fetching results from a COMPLETED job (e.g. 422
* validation errors). Those are handled in getVideoUrl().
*/
function mapFalStatusToVideoStatus(
falStatus: FalQueueStatus,
Expand Down Expand Up @@ -114,9 +118,26 @@ export class FalVideoAdapter<TModel extends FalModel> extends BaseVideoAdapter<
}

async getVideoUrl(jobId: string): Promise<VideoUrlResult> {
const result = await fal.queue.result(this.model, {
requestId: jobId,
})
let result
try {
result = await fal.queue.result(this.model, {
requestId: jobId,
})
} catch (error: any) {
// fal.ai may report COMPLETED status but throw on result fetch
// (e.g. 422 validation errors). Extract the detailed error info.
const detail = error?.body?.detail
if (Array.isArray(detail)) {
const messages = detail.map(
(d: { msg?: string; loc?: Array<string> }) =>
d.loc ? `${d.loc.join('.')}: ${d.msg}` : d.msg,
)
throw new Error(`Video generation failed: ${messages.join('; ')}`)
}
throw new Error(
`Failed to retrieve video result: ${error.message || error}`,
)
}

const data = result.data as FalVideoResultData

Expand Down
35 changes: 35 additions & 0 deletions packages/typescript/ai-fal/tests/video-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,18 @@ describe('Fal Video Adapter', () => {

expect(result.status).toBe('completed')
})

it('returns processing for unknown statuses', async () => {
mockQueueStatus.mockResolvedValueOnce({
status: 'UNKNOWN_STATUS',
})

const adapter = createAdapter()

const result = await adapter.getVideoStatus('job-unknown')

expect(result.status).toBe('processing')
})
})

describe('getVideoUrl', () => {
Expand Down Expand Up @@ -236,6 +248,29 @@ describe('Fal Video Adapter', () => {
expect(result.url).toBe('https://fal.media/files/video2.mp4')
})

it('throws detailed error when result fetch returns validation error', async () => {
mockQueueResult.mockRejectedValueOnce({
name: 'ValidationError',
status: 422,
message: 'Unprocessable Entity',
body: {
detail: [
{
type: 'string_too_long',
loc: ['body', 'prompt'],
msg: 'String should have at most 2500 characters',
},
],
},
})

const adapter = createAdapter()

await expect(adapter.getVideoUrl('job-failed')).rejects.toThrow(
'Video generation failed: body.prompt: String should have at most 2500 characters',
)
})

it('throws error when video URL is not found', async () => {
mockQueueResult.mockResolvedValueOnce({
data: {},
Expand Down
14 changes: 7 additions & 7 deletions packages/typescript/ai/src/activities/generateVideo/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -427,25 +427,25 @@ export async function getVideoJobStatus<
url: urlResult.url,
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Failed to get video URL'
aiEventClient.emit('video:request:completed', {
requestId,
provider: adapter.name,
model: adapter.model,
requestType: 'status',
jobId,
status: statusResult.status,
status: 'failed',
progress: statusResult.progress,
error:
error instanceof Error ? error.message : 'Failed to get video URL',
error: errorMessage,
duration: Date.now() - startTime,
timestamp: Date.now(),
})
// If URL fetch fails, still return status
// Provider reported completed but result fetch failed — treat as failed
return {
status: statusResult.status,
status: 'failed' as const,
progress: statusResult.progress,
error:
error instanceof Error ? error.message : 'Failed to get video URL',
error: errorMessage,
}
}
}
Expand Down
Loading
Loading