Skip to content

Commit fbf0e9f

Browse files
committed
fix(enrow,firecrawl): drop the fictional retry hint, guard NaN, surface search params
Enrow documents that error responses never carry retry_after, so parseRetryAfter was dead machinery -- and the test that 'proved' it worked asserted only a call count while honoring a header Enrow never sends. The 429 arm stays as bounded hygiene: Enrow bills on the result, so a hard throw would fail a job the workspace already paid for. Firecrawl's numeric params reached the body as NaN when an LLM emitted a word, serializing to null against an integer schema. sources/categories/ location/country now have subBlocks; tbs stays agent-only because a malformed value is silently ignored rather than rejected.
1 parent 0cb9a8d commit fbf0e9f

12 files changed

Lines changed: 385 additions & 41 deletions

File tree

apps/sim/blocks/blocks/firecrawl.ts

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,24 @@ import type { FirecrawlResponse } from '@/tools/firecrawl/types'
77
/** The document being parsed, whether it was uploaded or passed in by reference. */
88
const DOCUMENT_FIELD = ['fileUpload', 'fileReference'] as const
99

10+
/**
11+
* Normalize a `multiSelect` dropdown value into the string array the tool wants.
12+
*
13+
* The control persists either an array of option ids or a comma-joined string
14+
* depending on how the value was written, and an untouched field is `null`.
15+
* Returns `undefined` when nothing is selected so the caller can omit the key
16+
* and let Firecrawl apply its own default.
17+
*/
18+
function toSelectedIds(value: unknown): string[] | undefined {
19+
const entries = Array.isArray(value) ? value : [value]
20+
const ids = entries
21+
.filter((entry): entry is string => typeof entry === 'string')
22+
.flatMap((entry) => entry.split(','))
23+
.map((id) => id.trim())
24+
.filter((id) => id.length > 0)
25+
return ids.length > 0 ? ids : undefined
26+
}
27+
1028
export const FirecrawlBlock: BlockConfig<FirecrawlResponse> = {
1129
type: 'firecrawl',
1230
name: 'Firecrawl',
@@ -429,6 +447,68 @@ Example 2 - Product Data:
429447
},
430448
required: true,
431449
},
450+
{
451+
id: 'sources',
452+
title: 'Sources',
453+
type: 'dropdown',
454+
multiSelect: true,
455+
options: [
456+
{ label: 'Web', id: 'web' },
457+
{ label: 'News', id: 'news' },
458+
{ label: 'Images', id: 'images' },
459+
],
460+
placeholder: 'Web',
461+
description:
462+
'Which result sets to search. Each one is returned as its own array — web, news, images.',
463+
condition: {
464+
field: 'operation',
465+
value: 'search',
466+
},
467+
},
468+
{
469+
id: 'categories',
470+
title: 'Categories',
471+
type: 'dropdown',
472+
multiSelect: true,
473+
options: [
474+
{ label: 'GitHub', id: 'github' },
475+
{ label: 'Research', id: 'research' },
476+
{ label: 'PDF', id: 'pdf' },
477+
{ label: 'Developer', id: 'developer' },
478+
],
479+
placeholder: 'All categories',
480+
description:
481+
'Restrict web results to these categories. Developer cannot be combined with the others.',
482+
mode: 'advanced',
483+
condition: {
484+
field: 'operation',
485+
value: 'search',
486+
},
487+
},
488+
{
489+
id: 'location',
490+
title: 'Location',
491+
type: 'short-input',
492+
placeholder: 'Germany',
493+
description: 'Where to search from. Set Country alongside it for best results.',
494+
mode: 'advanced',
495+
condition: {
496+
field: 'operation',
497+
value: 'search',
498+
},
499+
},
500+
{
501+
id: 'country',
502+
title: 'Country',
503+
type: 'short-input',
504+
placeholder: 'US',
505+
description: 'ISO country code for geo-targeting, e.g. US, DE, JP.',
506+
mode: 'advanced',
507+
condition: {
508+
field: 'operation',
509+
value: 'search',
510+
},
511+
},
432512
{
433513
id: 'apiKey',
434514
title: 'API Key',
@@ -563,14 +643,21 @@ Example 2 - Product Data:
563643
if (mobile != null) result.mobile = mobile
564644
break
565645

566-
case 'search':
646+
case 'search': {
567647
if (query) result.query = query
568648
if (timeout) result.firecrawlTimeout = Number.parseInt(timeout)
569649
if (limit) result.limit = Number.parseInt(limit)
570650
if (params.ignoreInvalidURLs != null) {
571651
result.ignoreInvalidURLs = params.ignoreInvalidURLs
572652
}
653+
const sources = toSelectedIds(params.sources)
654+
if (sources) result.sources = sources
655+
const categories = toSelectedIds(params.categories)
656+
if (categories) result.categories = categories
657+
if (params.location) result.location = params.location
658+
if (params.country) result.country = params.country
573659
break
660+
}
574661

575662
case 'crawl':
576663
if (url) result.url = url

apps/sim/tools/enrow/find_email.test.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
1111
*/
1212
vi.mock('@sim/utils/helpers', () => ({ sleep: vi.fn().mockResolvedValue(undefined) }))
1313

14+
import { sleep } from '@sim/utils/helpers'
1415
import { enrowFindEmailTool } from '@/tools/enrow/find_email'
16+
import { POLL_INTERVAL_MS } from '@/tools/enrow/poll'
1517
import type {
1618
EnrowFindEmailParams,
1719
EnrowFindEmailResponse,
@@ -158,10 +160,10 @@ describe('enrow_find_email', () => {
158160
expect(result.output.email).toBe('john.doe@stripe.com')
159161
})
160162

161-
it('retries a 429 and honors Retry-After', async () => {
163+
it('still absorbs a 429, which Enrow documents as impossible on a GET poll', async () => {
162164
const fetchMock = vi
163165
.fn()
164-
.mockResolvedValueOnce(jsonResponse(429, { message: 'slow down' }, { 'retry-after': '5' }))
166+
.mockResolvedValueOnce(jsonResponse(429, { message: 'Too Many Requests' }))
165167
.mockResolvedValueOnce(jsonResponse(200, DOCUMENTED_FIND_BODY))
166168
vi.stubGlobal('fetch', fetchMock)
167169

@@ -175,6 +177,34 @@ describe('enrow_find_email', () => {
175177
expect(result.output.qualification).toBe('valid')
176178
})
177179

180+
it('escalates the retry delay and ignores an undocumented Retry-After header', async () => {
181+
const throttled = () =>
182+
jsonResponse(503, { message: 'upstream unavailable' }, { 'retry-after': '5' })
183+
const fetchMock = vi
184+
.fn()
185+
.mockResolvedValueOnce(throttled())
186+
.mockResolvedValueOnce(throttled())
187+
.mockResolvedValueOnce(throttled())
188+
.mockResolvedValueOnce(jsonResponse(200, DOCUMENTED_FIND_BODY))
189+
vi.stubGlobal('fetch', fetchMock)
190+
191+
await enrowFindEmailTool.postProcess!(submittedFindResult, findParams, executeTool)
192+
193+
const delays = vi.mocked(sleep).mock.calls.map(([ms]) => ms as number)
194+
195+
expect(delays).toHaveLength(4)
196+
expect(delays[0]).toBe(POLL_INTERVAL_MS)
197+
198+
// 3000 * 2 ** (attempt - 1) with the shared +/-20% jitter. Enrow documents
199+
// that error responses carry no retry hint, so a `Retry-After: 5` header
200+
// must not flatten this curve to a flat 5,000 ms.
201+
for (const [attempt, delay] of [delays[1], delays[2], delays[3]].entries()) {
202+
const exponential = POLL_INTERVAL_MS * 2 ** attempt
203+
expect(delay, `attempt ${attempt + 1} delay`).toBeGreaterThanOrEqual(exponential * 0.8)
204+
expect(delay, `attempt ${attempt + 1} delay`).toBeLessThan(exponential * 1.2)
205+
}
206+
})
207+
178208
it('gives up on a persistent 500 — an expired search id must surface, not loop', async () => {
179209
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(500, { message: 'unknown search id' }))
180210
vi.stubGlobal('fetch', fetchMock)

apps/sim/tools/enrow/hosting.test.ts

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,42 @@
22
* @vitest-environment node
33
*/
44
import { describe, expect, it } from 'vitest'
5-
import { ENROW_CREDIT_USD } from '@/tools/enrow/hosting'
5+
import { ENROW_CREDIT_USD, enrowHosting } from '@/tools/enrow/hosting'
66

7-
describe('enrow hosted-key pricing', () => {
8-
it('prices a credit at the entry Start tier ($17 / 1,000 credits)', () => {
7+
/**
8+
* Per-tool credit accounting lives in `tools/enrow-hosting.test.ts`. This file
9+
* covers the shared factory itself: the credits-to-dollars conversion and the
10+
* request-rate ceiling, both checked against Enrow's published limits rather
11+
* than against the constants the factory happens to hold.
12+
*/
13+
describe('enrowHosting', () => {
14+
const hosting = enrowHosting<{ apiKey: string }>((_params, output) => Number(output.credits ?? 0))
15+
16+
it('prices a credit at the published Start tier ($17 / 1,000 credits)', () => {
917
expect(ENROW_CREDIT_USD).toBeCloseTo(17 / 1000, 6)
1018
})
1119

12-
it('never bills below the entry tier rate', () => {
13-
expect(ENROW_CREDIT_USD).toBeGreaterThanOrEqual(0.017)
20+
it('converts the reported credits to dollars and reports the count as metadata', () => {
21+
expect(hosting.pricing.getCost!({ apiKey: 'k' }, { credits: 0.25 })).toEqual({
22+
cost: 0.25 * ENROW_CREDIT_USD,
23+
metadata: { credits: 0.25 },
24+
})
25+
expect(hosting.pricing.getCost!({ apiKey: 'k' }, { credits: 4 })).toEqual({
26+
cost: 4 * ENROW_CREDIT_USD,
27+
metadata: { credits: 4 },
28+
})
29+
})
30+
31+
it('stays inside the documented 10 req/s POST ceiling', () => {
32+
// https://docs.enrow.io/rate-limits — 10 requests per second per API key on
33+
// every POST endpoint. Anything above 600/min would exceed it outright.
34+
expect(hosting.rateLimit).toMatchObject({ mode: 'per_request' })
35+
expect(hosting.rateLimit!.requestsPerMinute).toBeLessThanOrEqual(600)
36+
expect(hosting.rateLimit!.requestsPerMinute).toBeGreaterThan(0)
37+
})
38+
39+
it('reads the hosted key into the tool `apiKey` param', () => {
40+
expect(hosting.apiKeyParam).toBe('apiKey')
41+
expect(hosting.envKeyPrefix).toBe('ENROW_API_KEY')
1442
})
1543
})

apps/sim/tools/enrow/hosting.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,17 @@ export const ENROW_API_KEY_PREFIX = 'ENROW_API_KEY'
99
/**
1010
* Dollar cost of a single Enrow credit.
1111
*
12-
* Based on the entry Start plan ($17/month, 1,000 credits = $0.017/credit),
13-
* which matches Enrow's own Start unit price of $0.017/email for the finder.
14-
* Per-credit drops at higher tiers (Pro $0.0087, Scale $0.00794), so pricing at
15-
* the entry tier guarantees hosted-key cost recovery rather than under-billing.
16-
* The email finder costs 1 credit per valid result and the email verifier
17-
* costs 0.25 credits per verification.
18-
* Source: https://enrow.io/pricing
12+
* Sourced from the published plan rates on https://enrow.io/pricing: Start
13+
* $17 / 1,000 credits ($0.017/credit), Pro $87 / 10,000 ($0.0087), Scale
14+
* $397 / 50,000 ($0.00794). The email finder costs 1 credit per valid result
15+
* and the email verifier costs 0.25 credits per verification.
16+
*
17+
* UNCONFIRMED — do not treat as established: whether $0.017 actually recovers
18+
* hosted-key cost. The pricing page toggles between Annually (-40%), Monthly
19+
* (-30%), and Pay-as-you-go, and we could not determine which mode the $17
20+
* figure belongs to without account visibility. If $17 is a discounted rate,
21+
* undiscounted pay-as-you-go is higher and this constant under-bills. Settling
22+
* it needs a look at an actual Enrow invoice.
1923
*/
2024
export const ENROW_CREDIT_USD = 0.017
2125

apps/sim/tools/enrow/poll.ts

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { sleep } from '@sim/utils/helpers'
2-
import { backoffWithJitter, parseRetryAfter } from '@sim/utils/retry'
2+
import { backoffWithJitter } from '@sim/utils/retry'
33

44
/** Base gap between two in-progress (202) polls. */
55
export const POLL_INTERVAL_MS = 3000
@@ -10,15 +10,29 @@ export const MAX_POLL_TIME_MS = 120_000
1010
/**
1111
* Consecutive transient failures tolerated before the loop gives up.
1212
*
13-
* Enrow documents 429 and 5xx as "safe to retry after a short delay", but it
14-
* also returns 500 for an unknown or expired search id on the single
15-
* endpoints. That makes 500 ambiguous, so the retry is deliberately bounded:
16-
* an expired id simply re-fails on each attempt and the loop exits with the
17-
* upstream status and body instead of masking it.
13+
* Enrow documents 5xx as "safe to retry after a short delay", but it also
14+
* returns 500 for an unknown or expired search id on the single endpoints.
15+
* That makes 500 ambiguous, so the retry is deliberately bounded: an expired
16+
* id simply re-fails on each attempt and the loop exits with the upstream
17+
* status and body instead of masking it.
18+
*
19+
* Source: https://docs.enrow.io/status-codes
1820
*/
1921
export const MAX_TRANSIENT_RETRIES = 3
2022

21-
/** The statuses Enrow documents as retryable. */
23+
/**
24+
* Statuses worth another bounded attempt.
25+
*
26+
* 5xx is the load-bearing arm: Enrow documents those as safe to retry.
27+
*
28+
* 429 cannot occur on this path today — Enrow documents GET endpoints as not
29+
* rate limited, and this loop only issues GETs — so it is kept purely as a
30+
* bounded fallback against an undocumented change. It is cheap (the retry
31+
* count caps it at MAX_TRANSIENT_RETRIES) and strictly safer than the
32+
* alternative, which is failing a job Enrow has already charged for.
33+
*
34+
* Source: https://docs.enrow.io/rate-limits
35+
*/
2236
function isRetryableStatus(status: number): boolean {
2337
return status === 429 || status >= 500
2438
}
@@ -58,11 +72,13 @@ export async function pollEnrowJob(
5872
if (!pollResponse.ok) {
5973
if (isRetryableStatus(pollResponse.status) && transientFailures < MAX_TRANSIENT_RETRIES) {
6074
transientFailures += 1
61-
delayMs = backoffWithJitter(
62-
transientFailures,
63-
parseRetryAfter(pollResponse.headers.get('retry-after')),
64-
{ baseMs: POLL_INTERVAL_MS, maxMs: MAX_POLL_TIME_MS / 4 }
65-
)
75+
// No `Retry-After` is read: Enrow documents that its error responses
76+
// carry no retry hint and tells callers to use their own exponential
77+
// backoff. Source: https://docs.enrow.io/error-handling
78+
delayMs = backoffWithJitter(transientFailures, null, {
79+
baseMs: POLL_INTERVAL_MS,
80+
maxMs: MAX_POLL_TIME_MS / 4,
81+
})
6682
continue
6783
}
6884
const errorText = await pollResponse.text()

apps/sim/tools/firecrawl/map.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,27 @@ describe('firecrawl map timeout is not the transport deadline', () => {
6565
expect(body.timeout).toBe(45000)
6666
})
6767
})
68+
69+
describe('firecrawl map numeric coercion', () => {
70+
it('drops a non-numeric limit rather than putting JSON null on the wire', () => {
71+
const body = resolveBody({ ...mapParams, limit: 'ten' as unknown as number })
72+
73+
expect(Object.hasOwn(body, 'limit')).toBe(false)
74+
})
75+
76+
it('drops a non-numeric firecrawlTimeout rather than putting JSON null on the wire', () => {
77+
const body = resolveBody({ ...mapParams, firecrawlTimeout: 'soon' as unknown as number })
78+
79+
expect(Object.hasOwn(body, 'timeout')).toBe(false)
80+
})
81+
82+
it('still forwards numeric strings, which the block short-inputs produce', () => {
83+
const body = resolveBody({
84+
...mapParams,
85+
limit: '5' as unknown as number,
86+
firecrawlTimeout: '45000' as unknown as number,
87+
})
88+
89+
expect(body).toMatchObject({ limit: 5, timeout: 45000 })
90+
})
91+
})

apps/sim/tools/firecrawl/map.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { firecrawlHosting } from '@/tools/firecrawl/hosting'
22
import type { MapParams, MapResponse } from '@/tools/firecrawl/types'
33
import { MAP_DOCUMENT_OUTPUT_PROPERTIES } from '@/tools/firecrawl/types'
4+
import { finiteNumber } from '@/tools/firecrawl/utils'
45
import type { ToolConfig } from '@/tools/types'
56

67
export const mapTool: ToolConfig<MapParams, MapResponse> = {
@@ -89,8 +90,10 @@ export const mapTool: ToolConfig<MapParams, MapResponse> = {
8990
body.includeSubdomains = params.includeSubdomains
9091
if (typeof params.ignoreQueryParameters === 'boolean')
9192
body.ignoreQueryParameters = params.ignoreQueryParameters
92-
if (params.limit) body.limit = Number(params.limit)
93-
if (params.firecrawlTimeout) body.timeout = Number(params.firecrawlTimeout)
93+
const limit = finiteNumber(params.limit)
94+
if (limit !== undefined) body.limit = limit
95+
const firecrawlTimeout = finiteNumber(params.firecrawlTimeout)
96+
if (firecrawlTimeout !== undefined) body.timeout = firecrawlTimeout
9497
if (params.location) body.location = params.location
9598

9699
return body

apps/sim/tools/firecrawl/scrape.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,32 @@ describe('firecrawl scrape timeout is not the transport deadline', () => {
6363
expect(body.timeout).toBe(45000)
6464
})
6565
})
66+
67+
describe('firecrawl scrape numeric coercion', () => {
68+
it('drops a non-numeric firecrawlTimeout rather than putting JSON null on the wire', () => {
69+
const body = resolveBody({ ...scrapeParams, firecrawlTimeout: 'soon' as unknown as number })
70+
71+
expect(Object.hasOwn(body, 'timeout')).toBe(false)
72+
})
73+
74+
it('drops non-numeric maxAge and waitFor, which arrive via scrapeOptions passthrough', () => {
75+
const body = resolveBody({
76+
...scrapeParams,
77+
maxAge: 'fresh' as unknown as number,
78+
waitFor: 'a bit' as unknown as number,
79+
} as ScrapeParams)
80+
81+
expect(Object.hasOwn(body, 'maxAge')).toBe(false)
82+
expect(Object.hasOwn(body, 'waitFor')).toBe(false)
83+
})
84+
85+
it('still forwards numeric strings, which the block short-inputs produce', () => {
86+
const body = resolveBody({
87+
...scrapeParams,
88+
firecrawlTimeout: '45000' as unknown as number,
89+
waitFor: '250' as unknown as number,
90+
} as ScrapeParams)
91+
92+
expect(body).toMatchObject({ timeout: 45000, waitFor: 250 })
93+
})
94+
})

0 commit comments

Comments
 (0)