Skip to content

Commit 26d8243

Browse files
tombeckenhamclaudeAlemTuzlak
authored
fix(ai-fal): handle errors from fal result fetch on completed jobs (#396)
* fix(ai-fal): handle FAILED queue status to prevent infinite polling Map fal.ai's FAILED queue status to 'failed' instead of falling through to the default case which incorrectly returned 'processing'. Unknown statuses now also map to 'failed' as a safety net. Error details from fal responses are surfaced in VideoStatusResult.error. Closes #394 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(ai-fal): handle errors from fal result fetch on completed jobs fal.ai never returns a FAILED queue status — invalid jobs go through IN_PROGRESS → COMPLETED, and the real error (e.g. 422 validation) only surfaces when calling fal.queue.result(). This extracts detailed error info from the result fetch, removes the fictional FAILED status handling, and returns failed status from getVideoJobStatus when the result fetch throws. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: update changeset to reflect actual fix Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Alem Tuzlak <t.zlak@hotmail.com>
1 parent e36f2af commit 26d8243

6 files changed

Lines changed: 402 additions & 91 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@tanstack/ai-fal': patch
3+
'@tanstack/ai': patch
4+
---
5+
6+
fix: handle errors from fal result fetch on completed jobs
7+
8+
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.

packages/typescript/ai-fal/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141
"video-generation"
4242
],
4343
"dependencies": {
44-
"@fal-ai/client": "^1.9.1"
44+
"@fal-ai/client": "^1.9.4"
4545
},
4646
"devDependencies": {
4747
"@tanstack/ai": "workspace:*",

packages/typescript/ai-fal/src/adapters/video.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ interface FalVideoResultData {
3131

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

116120
async getVideoUrl(jobId: string): Promise<VideoUrlResult> {
117-
const result = await fal.queue.result(this.model, {
118-
requestId: jobId,
119-
})
121+
let result
122+
try {
123+
result = await fal.queue.result(this.model, {
124+
requestId: jobId,
125+
})
126+
} catch (error: any) {
127+
// fal.ai may report COMPLETED status but throw on result fetch
128+
// (e.g. 422 validation errors). Extract the detailed error info.
129+
const detail = error?.body?.detail
130+
if (Array.isArray(detail)) {
131+
const messages = detail.map(
132+
(d: { msg?: string; loc?: Array<string> }) =>
133+
d.loc ? `${d.loc.join('.')}: ${d.msg}` : d.msg,
134+
)
135+
throw new Error(`Video generation failed: ${messages.join('; ')}`)
136+
}
137+
throw new Error(
138+
`Failed to retrieve video result: ${error.message || error}`,
139+
)
140+
}
120141

121142
const data = result.data as FalVideoResultData
122143

packages/typescript/ai-fal/tests/video-adapter.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,18 @@ describe('Fal Video Adapter', () => {
199199

200200
expect(result.status).toBe('completed')
201201
})
202+
203+
it('returns processing for unknown statuses', async () => {
204+
mockQueueStatus.mockResolvedValueOnce({
205+
status: 'UNKNOWN_STATUS',
206+
})
207+
208+
const adapter = createAdapter()
209+
210+
const result = await adapter.getVideoStatus('job-unknown')
211+
212+
expect(result.status).toBe('processing')
213+
})
202214
})
203215

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

251+
it('throws detailed error when result fetch returns validation error', async () => {
252+
mockQueueResult.mockRejectedValueOnce({
253+
name: 'ValidationError',
254+
status: 422,
255+
message: 'Unprocessable Entity',
256+
body: {
257+
detail: [
258+
{
259+
type: 'string_too_long',
260+
loc: ['body', 'prompt'],
261+
msg: 'String should have at most 2500 characters',
262+
},
263+
],
264+
},
265+
})
266+
267+
const adapter = createAdapter()
268+
269+
await expect(adapter.getVideoUrl('job-failed')).rejects.toThrow(
270+
'Video generation failed: body.prompt: String should have at most 2500 characters',
271+
)
272+
})
273+
239274
it('throws error when video URL is not found', async () => {
240275
mockQueueResult.mockResolvedValueOnce({
241276
data: {},

packages/typescript/ai/src/activities/generateVideo/index.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -427,25 +427,25 @@ export async function getVideoJobStatus<
427427
url: urlResult.url,
428428
}
429429
} catch (error) {
430+
const errorMessage =
431+
error instanceof Error ? error.message : 'Failed to get video URL'
430432
aiEventClient.emit('video:request:completed', {
431433
requestId,
432434
provider: adapter.name,
433435
model: adapter.model,
434436
requestType: 'status',
435437
jobId,
436-
status: statusResult.status,
438+
status: 'failed',
437439
progress: statusResult.progress,
438-
error:
439-
error instanceof Error ? error.message : 'Failed to get video URL',
440+
error: errorMessage,
440441
duration: Date.now() - startTime,
441442
timestamp: Date.now(),
442443
})
443-
// If URL fetch fails, still return status
444+
// Provider reported completed but result fetch failed — treat as failed
444445
return {
445-
status: statusResult.status,
446+
status: 'failed' as const,
446447
progress: statusResult.progress,
447-
error:
448-
error instanceof Error ? error.message : 'Failed to get video URL',
448+
error: errorMessage,
449449
}
450450
}
451451
}

0 commit comments

Comments
 (0)