Skip to content

Commit 2d7361b

Browse files
committed
fix(falai): preserve receipts across result retrieval failures
1 parent b73d52b commit 2d7361b

7 files changed

Lines changed: 357 additions & 90 deletions

File tree

‎apps/sim/lib/internal/image/operations.test.ts‎

Lines changed: 48 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
1414

1515
const mocks = vi.hoisted(() => ({
1616
fetch: vi.fn(),
17-
getFalAICostMetadata: vi.fn(),
17+
getFalAIEstimatedCostMetadata: vi.fn(),
1818
getFalAIBillingReceipt: vi.fn(),
1919
}))
2020

@@ -25,7 +25,8 @@ vi.mock('@sim/utils/helpers', () => utilsHelpersMock)
2525
vi.mock('@/lib/core/execution-limits', () => executionLimitsMock)
2626

2727
vi.mock('@/lib/tools/falai-pricing', () => ({
28-
getFalAICostMetadata: mocks.getFalAICostMetadata,
28+
FALAI_COST_LOOKUP_TIMEOUT_MS: 10_000,
29+
getFalAIEstimatedCostMetadata: mocks.getFalAIEstimatedCostMetadata,
2930
getFalAIBillingReceipt: mocks.getFalAIBillingReceipt,
3031
}))
3132

@@ -54,7 +55,8 @@ const falInput = {
5455
describe('image operations', () => {
5556
beforeEach(() => {
5657
mocks.fetch.mockReset()
57-
mocks.getFalAICostMetadata.mockReset()
58+
mocks.getFalAIEstimatedCostMetadata.mockReset()
59+
mocks.getFalAIBillingReceipt.mockReset().mockResolvedValue(undefined)
5860
mockSecureFetchWithPinnedIP.mockReset()
5961
vi.stubGlobal('fetch', mocks.fetch)
6062
mockInterruptibleSleep.mockResolvedValue(undefined)
@@ -187,15 +189,17 @@ describe('image operations', () => {
187189
it('retains the completed fal receipt when delivery is interrupted', async () => {
188190
const controller = new AbortController()
189191
mocks.fetch.mockResolvedValueOnce(Response.json({ request_id: 'paid-job' }))
190-
mocks.getFalAICostMetadata.mockImplementation(async ({ signal }: { signal?: AbortSignal }) => {
191-
signal?.throwIfAborted()
192-
return {
193-
requestId: 'paid-job',
194-
endpointId: 'fal-ai/nano-banana-2',
195-
costDollars: 0.1,
196-
source: 'billing_events',
192+
mocks.getFalAIBillingReceipt.mockImplementation(
193+
async ({ signal }: { signal?: AbortSignal }) => {
194+
signal?.throwIfAborted()
195+
return {
196+
requestId: 'paid-job',
197+
endpointId: 'fal-ai/nano-banana-2',
198+
costDollars: 0.1,
199+
source: 'billing_events',
200+
}
197201
}
198-
})
202+
)
199203
mockSecureFetchWithPinnedIP.mockResolvedValueOnce(Response.json({ status: 'COMPLETED' }))
200204
mockSecureFetchWithPinnedIP.mockImplementationOnce(async () => {
201205
controller.abort()
@@ -212,10 +216,10 @@ describe('image operations', () => {
212216
}
213217
)
214218
).rejects.toMatchObject({ name: 'AbortError' })
215-
expect(mocks.getFalAICostMetadata).toHaveBeenCalledWith(
219+
expect(mocks.getFalAIBillingReceipt).toHaveBeenCalledWith(
216220
expect.objectContaining({ requestId: 'paid-job' })
217221
)
218-
expect(mocks.getFalAICostMetadata.mock.calls[0][0].signal).toBeUndefined()
222+
expect(mocks.getFalAIBillingReceipt.mock.calls[0][0].signal).toBeUndefined()
219223
})
220224
it.each([422, 500])(
221225
'reports HTTP %i generation failures without speculative delivery receipts',
@@ -233,7 +237,7 @@ describe('image operations', () => {
233237
{ status }
234238
)
235239
)
236-
mocks.getFalAICostMetadata.mockResolvedValue({
240+
mocks.getFalAIEstimatedCostMetadata.mockResolvedValue({
237241
requestId: 'rejected-job',
238242
source: 'fallback_floor',
239243
costDollars: 0.05,
@@ -258,7 +262,7 @@ describe('image operations', () => {
258262
'secret-prompt'
259263
)
260264
expect(receipts).toEqual([])
261-
expect(mocks.getFalAICostMetadata).not.toHaveBeenCalled()
265+
expect(mocks.getFalAIEstimatedCostMetadata).not.toHaveBeenCalled()
262266
expect(mockUploadCopilotFile).not.toHaveBeenCalled()
263267
}
264268
)
@@ -309,4 +313,33 @@ describe('image operations', () => {
309313
}
310314
}
311315
)
316+
it('reconciles exact image receipts when a completed queue status contains an error', async () => {
317+
mocks.fetch.mockResolvedValueOnce(Response.json({ request_id: 'image-queue-error' }))
318+
mockSecureFetchWithPinnedIP.mockResolvedValueOnce(
319+
Response.json({ status: 'COMPLETED', error: 'sensitive provider error' })
320+
)
321+
mocks.getFalAIBillingReceipt.mockResolvedValueOnce({
322+
requestId: 'image-queue-error',
323+
endpointId: 'fal-ai/nano-banana-2',
324+
source: 'billing_events',
325+
costDollars: 0.1,
326+
})
327+
const receipts: string[] = []
328+
const response = await observeServiceCosts(
329+
async () => {},
330+
() =>
331+
executeImageGeneration(
332+
{ ...falInput, useHostedCostTracking: true },
333+
{ userId: 'user', requestId: 'queue-error' }
334+
),
335+
async (receipt) => {
336+
receipts.push(receipt)
337+
}
338+
)
339+
expect(response.ok).toBe(false)
340+
expect(JSON.stringify(await response.json())).not.toContain('sensitive provider error')
341+
expect(receipts).toHaveLength(1)
342+
expect(receipts[0]).toContain('0.1 (billing_events)')
343+
expect(mockSecureFetchWithPinnedIP).toHaveBeenCalledTimes(1)
344+
})
312345
})

‎apps/sim/lib/internal/image/operations.ts‎

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -509,20 +509,16 @@ async function generateWithFalAI(
509509

510510
const status = getStringProperty(statusData, 'status')
511511
if (status === 'COMPLETED') {
512-
const statusError = statusData.error
513-
if (statusError) {
514-
throw new Error(`Fal.ai generation failed: ${getFalAIErrorMessage(statusError)}`)
515-
}
516-
517512
const { data: resultData, cost: falaiCost } = await fetchFalAIResult({
518513
apiKey,
519514
endpointId: endpoint,
520515
requestId: falRequestId,
516+
queueError: statusData.error,
521517
maxResponseBytes: MAX_IMAGE_JSON_BYTES,
522518
trackCost: body.useHostedCostTracking,
523519
onCost: onFalCost,
524520
signal,
525-
fetchResponse: async () => {
521+
fetchResponse: async (resultSignal) => {
526522
const resultUrl = falQueueUrl(
527523
getStringProperty(statusData, 'response_url') || responseUrl
528524
)
@@ -533,7 +529,7 @@ async function generateWithFalAI(
533529
maxRedirects: 0,
534530
headers: { Authorization: `Key ${apiKey}` },
535531
maxResponseBytes: MAX_IMAGE_JSON_BYTES,
536-
signal,
532+
signal: resultSignal,
537533
})
538534
},
539535
})

‎apps/sim/lib/internal/video/client.test.ts‎

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -566,6 +566,161 @@ describe('Video provider client', () => {
566566
}
567567
)
568568

569+
it.each([
570+
{ status: 429, body: 'Rate limit exceeded' },
571+
{ status: 503, body: '<html>Service unavailable</html>' },
572+
])(
573+
'retains reconciliation when result retrieval fails with HTTP $status',
574+
async ({ status, body }) => {
575+
let submissions = 0
576+
vi.stubGlobal('fetch', async (url: string | URL) => {
577+
const path = new URL(url).pathname
578+
if (path.endsWith('/status')) return jsonResponse({ status: 'COMPLETED' })
579+
if (path.includes('/requests/')) return new Response(body, { status })
580+
if (path.endsWith('/billing-events')) return jsonResponse({ billing_events: [] })
581+
if (path.endsWith('/estimate')) return jsonResponse({ total_cost: 0.25 })
582+
submissions++
583+
return jsonResponse({ request_id: `unavailable-${status}` })
584+
})
585+
const receipts: string[] = []
586+
const result = observeServiceCosts(
587+
async () => {},
588+
() =>
589+
executeVideoOperation(
590+
{
591+
provider: 'falai',
592+
model: 'veo-3.1',
593+
prompt: 'A sunrise',
594+
apiKey: `delivery-${status}`,
595+
useHostedCostTracking: true,
596+
},
597+
{ requestId: 'trace', userId: 'user', headers: new Headers() }
598+
),
599+
async (receipt) => {
600+
receipts.push(receipt)
601+
}
602+
)
603+
const rejection = expect(result).rejects.toMatchObject({ status: 502 })
604+
await Promise.all([rejection, vi.advanceTimersByTimeAsync(6000)])
605+
expect(receipts).toHaveLength(1)
606+
expect(receipts[0]).toContain(`unavailable-${status}`)
607+
expect(receipts[0]).toContain('0.25 (historical_estimate)')
608+
expect(submissions).toBe(1)
609+
}
610+
)
611+
612+
it.each(['headers', 'body'])(
613+
'captures the exact receipt before a stalled result %s settles',
614+
async (stall) => {
615+
let resultSignal: AbortSignal | null | undefined
616+
const costs: number[] = []
617+
vi.stubGlobal('fetch', async (url: string | URL, init?: RequestInit) => {
618+
const path = new URL(url).pathname
619+
if (path.endsWith('/status')) return jsonResponse({ status: 'COMPLETED' })
620+
if (path.endsWith('/billing-events'))
621+
return jsonResponse({
622+
billing_events: [
623+
{
624+
request_id: 'stalled-result',
625+
endpoint_id: 'fal-ai/veo3.1',
626+
cost_estimate_nano_usd: 400000000,
627+
},
628+
],
629+
})
630+
if (path.includes('/requests/')) {
631+
resultSignal = init?.signal
632+
return stall === 'headers'
633+
? new Promise<Response>(() => {})
634+
: new Response(
635+
new ReadableStream({
636+
start(controller) {
637+
controller.enqueue(new TextEncoder().encode('{'))
638+
},
639+
})
640+
)
641+
}
642+
return jsonResponse({ request_id: 'stalled-result' })
643+
})
644+
const result = generateVideo(
645+
{
646+
provider: 'falai',
647+
model: 'veo-3.1',
648+
prompt: 'A sunrise',
649+
apiKey: 'stall-key',
650+
useHostedCostTracking: true,
651+
},
652+
{
653+
requestId: 'trace',
654+
onFalCost: (cost) => {
655+
costs.push(cost.costDollars)
656+
},
657+
}
658+
)
659+
let outcome: unknown = 'pending'
660+
void result.then(
661+
(value) => {
662+
outcome = value
663+
},
664+
(error) => {
665+
outcome = error
666+
}
667+
)
668+
await vi.advanceTimersByTimeAsync(5000)
669+
const earlyCosts = [...costs]
670+
await vi.advanceTimersByTimeAsync(30001)
671+
expect(earlyCosts).toEqual([0.4])
672+
expect(outcome).toMatchObject({ name: 'DeadlineExceededError' })
673+
expect(resultSignal?.aborted).toBe(true)
674+
expect(costs).toEqual([0.4])
675+
}
676+
)
677+
678+
it('reconciles an exact receipt for a terminal queue error without fetching or estimating a result', async () => {
679+
const paths: string[] = []
680+
vi.stubGlobal('fetch', async (url: string | URL) => {
681+
const path = new URL(url).pathname
682+
paths.push(path)
683+
if (path.endsWith('/status'))
684+
return jsonResponse({ status: 'COMPLETED', error: 'sensitive provider error' })
685+
if (path.endsWith('/billing-events'))
686+
return jsonResponse({
687+
billing_events: [
688+
{
689+
request_id: 'queue-error',
690+
endpoint_id: 'fal-ai/veo3.1',
691+
cost_estimate_nano_usd: 400000000,
692+
},
693+
],
694+
})
695+
return jsonResponse({ request_id: 'queue-error' })
696+
})
697+
const receipts: string[] = []
698+
const result = observeServiceCosts(
699+
async () => {},
700+
() =>
701+
executeVideoOperation(
702+
{
703+
provider: 'falai',
704+
model: 'veo-3.1',
705+
prompt: 'A sunrise',
706+
apiKey: 'queue-key',
707+
useHostedCostTracking: true,
708+
},
709+
{ requestId: 'trace', userId: 'user', headers: new Headers() }
710+
),
711+
async (receipt) => {
712+
receipts.push(receipt)
713+
}
714+
)
715+
const rejected = expect(result).rejects.toThrow()
716+
await Promise.all([rejected, vi.advanceTimersByTimeAsync(5000)])
717+
expect(receipts).toHaveLength(1)
718+
expect(receipts[0]).toContain('0.4 (billing_events)')
719+
expect(
720+
paths.some((path) => path.endsWith('/estimate') || path.endsWith('/requests/queue-error'))
721+
).toBe(false)
722+
})
723+
569724
it('preserves an exact charge even when the provider returns a failed result', async () => {
570725
vi.stubGlobal('fetch', async (url: string | URL) => {
571726
const path = new URL(url).pathname

‎apps/sim/lib/internal/video/client.ts‎

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -424,24 +424,20 @@ async function generateWithFalAI(
424424
}
425425

426426
if (getStringProperty(statusData, 'status') === 'COMPLETED') {
427-
const statusError = statusData.error
428-
if (statusError) {
429-
throw new Error(`Fal.ai generation failed: ${getFalAIErrorMessage(statusError)}`)
430-
}
431-
432427
const { data: resultData, cost: falaiCost } = await fetchFalAIResult({
433428
apiKey,
434429
endpointId: endpoint,
435430
requestId: requestIdFal,
431+
queueError: statusData.error,
436432
maxResponseBytes: MAX_VIDEO_JSON_BYTES,
437433
trackCost: useHostedCostTracking,
438434
onCost: context.onFalCost,
439435
signal,
440-
fetchResponse: () =>
436+
fetchResponse: (resultSignal) =>
441437
fetch(falQueueUrl(getStringProperty(statusData, 'response_url') || responseUrl), {
442438
redirect: 'error',
443439
headers: { Authorization: `Key ${apiKey}` },
444-
signal,
440+
signal: resultSignal,
445441
}),
446442
})
447443

0 commit comments

Comments
 (0)