Skip to content

Commit 8eebd6e

Browse files
fix(enrichments): project provider failures (#6917)
* fix(enrichments): project provider failures * fix(enrichments): share Prospeo failure projection
1 parent 42f6287 commit 8eebd6e

22 files changed

Lines changed: 259 additions & 12 deletions

apps/sim/enrichments/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ export type {
44
EnrichmentInputField,
55
EnrichmentOutputField,
66
EnrichmentProvider,
7+
EnrichmentProviderFailure,
8+
EnrichmentProviderFailureProjection,
79
EnrichmentRegistry,
810
EnrichmentRunContext,
911
} from './types'

apps/sim/enrichments/phone-number/phone-number.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,27 @@ describe('phone-number enrichment cascade', () => {
7676
expect(p.buildParams({ fullName: 'John Doe' })).toBeNull()
7777
expect(p.mapOutput({ person: { mobile: { mobile: '+1555' } } })).toEqual({ phone: '+1555' })
7878
})
79+
80+
it('recognizes only Prospeo NO_MATCH as a clean miss', () => {
81+
expect(
82+
p.projectFailure({
83+
error: 'NO_MATCH',
84+
output: { status: 400, data: { error: true, error_code: 'NO_MATCH' } },
85+
})
86+
).toEqual({ status: 'no_match' })
87+
expect(
88+
p.projectFailure({
89+
error: 'INVALID_API_KEY',
90+
output: { status: 400, data: { error: true, error_code: 'INVALID_API_KEY' } },
91+
})
92+
).toEqual({ status: 'error', error: 'INVALID_API_KEY' })
93+
expect(
94+
p.projectFailure({
95+
error: 'Bad Request',
96+
output: { status: 400, data: { error: true } },
97+
})
98+
).toEqual({ status: 'error', error: 'Bad Request' })
99+
})
79100
})
80101

81102
describe('leadmagic', () => {

apps/sim/enrichments/phone-number/phone-number.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Phone } from '@sim/emcn/icons'
22
import { filterUndefined } from '@sim/utils/object'
3+
import { projectProspeoEnrichmentFailure } from '@/enrichments/provider-failures/prospeo'
34
import { firstNonEmpty, normalizeDomain, str, toolProvider } from '@/enrichments/providers'
45
import type { EnrichmentConfig } from '@/enrichments/types'
56

@@ -103,6 +104,7 @@ export const phoneNumberEnrichment: EnrichmentConfig = {
103104
enrich_mobile: true,
104105
})
105106
},
107+
projectFailure: projectProspeoEnrichmentFailure,
106108
mapOutput: (output) => {
107109
const person = output.person as Record<string, unknown> | undefined
108110
const mobile = person?.mobile as Record<string, unknown> | undefined
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { isRecordLike } from '@sim/utils/object'
2+
import { projectEnrichmentProviderFailure } from '@/enrichments/providers'
3+
import type {
4+
EnrichmentProviderFailure,
5+
EnrichmentProviderFailureProjection,
6+
} from '@/enrichments/types'
7+
8+
/** Projects Prospeo's documented `NO_MATCH` error as a clean provider miss. */
9+
export function projectProspeoEnrichmentFailure(
10+
failure: EnrichmentProviderFailure
11+
): EnrichmentProviderFailureProjection {
12+
if (
13+
isRecordLike(failure.output) &&
14+
isRecordLike(failure.output.data) &&
15+
failure.output.data.error_code === 'NO_MATCH'
16+
) {
17+
return { status: 'no_match' }
18+
}
19+
return projectEnrichmentProviderFailure(failure)
20+
}

apps/sim/enrichments/providers.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
import { isRecordLike } from '@sim/utils/object'
12
import type {
23
EnrichmentInputField,
34
EnrichmentOutputField,
45
EnrichmentProvider,
6+
EnrichmentProviderFailure,
7+
EnrichmentProviderFailureProjection,
58
} from '@/enrichments/types'
69

710
/**
@@ -56,13 +59,30 @@ export function splitName(fullName: unknown): { firstName: string; lastName: str
5659
return { firstName: parts[0], lastName: parts.slice(1).join(' ') }
5760
}
5861

62+
/** Projects the standard provider failure contract into a cascade outcome. */
63+
export function projectEnrichmentProviderFailure(
64+
failure: EnrichmentProviderFailure
65+
): EnrichmentProviderFailureProjection {
66+
if (isRecordLike(failure.output) && failure.output.status === 404) {
67+
return { status: 'no_match' }
68+
}
69+
return { status: 'error', error: failure.error }
70+
}
71+
72+
type EnrichmentProviderDefinition = Omit<EnrichmentProvider, 'projectFailure'> & {
73+
projectFailure?: EnrichmentProvider['projectFailure']
74+
}
75+
5976
/**
6077
* Declares a tool-backed enrichment provider as plain data. Keeping this free of
6178
* any `@/tools` reference (the cascade runner does the `executeTool` call) means
6279
* the enrichment catalog stays client-safe — the table UI imports it only for
6380
* metadata. Workspace scope and BYOK / hosted-key injection are handled by the
6481
* runner when it executes `toolId`.
6582
*/
66-
export function toolProvider(provider: EnrichmentProvider): EnrichmentProvider {
67-
return provider
83+
export function toolProvider(provider: EnrichmentProviderDefinition): EnrichmentProvider {
84+
return {
85+
...provider,
86+
projectFailure: provider.projectFailure ?? projectEnrichmentProviderFailure,
87+
}
6888
}

apps/sim/enrichments/run.test.ts

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
66
const { mockExecuteTool } = vi.hoisted(() => ({ mockExecuteTool: vi.fn() }))
77
vi.mock('@/tools', () => ({ executeTool: mockExecuteTool }))
88

9+
import { projectEnrichmentProviderFailure, toolProvider } from '@/enrichments/providers'
910
import { runEnrichment, skippedEnrichmentDetail } from '@/enrichments/run'
1011
import type { EnrichmentConfig, EnrichmentProvider } from '@/enrichments/types'
1112
import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
@@ -16,16 +17,18 @@ function prov(
1617
id: string,
1718
opts: {
1819
build?: (inputs: Record<string, unknown>) => Record<string, unknown> | null
20+
projectFailure?: EnrichmentProvider['projectFailure']
1921
map?: (output: Record<string, unknown>) => Record<string, unknown> | null
2022
} = {}
2123
): EnrichmentProvider {
22-
return {
24+
return toolProvider({
2325
id,
2426
label: id.toUpperCase(),
2527
toolId: `tool_${id}`,
2628
buildParams: opts.build ?? (() => ({ q: 'x' })),
29+
projectFailure: opts.projectFailure,
2730
mapOutput: opts.map ?? ((o) => (o.email ? { email: o.email } : null)),
28-
}
31+
})
2932
}
3033

3134
function config(providers: EnrichmentProvider[]): EnrichmentConfig {
@@ -138,6 +141,82 @@ describe('runEnrichment cascade detail', () => {
138141
expect(outcome.detail.providers.map((p) => p.status)).toEqual(['no_match'])
139142
})
140143

144+
it('continues after a provider translates a documented error into a clean miss', async () => {
145+
mockExecuteTool.mockImplementation((toolId: string) => {
146+
if (toolId === 'tool_a') {
147+
return {
148+
success: false,
149+
error: 'NO_MATCH',
150+
output: { status: 400, data: { error: true, error_code: 'NO_MATCH' } },
151+
}
152+
}
153+
return { success: true, output: { email: 'j@acme.com' } }
154+
})
155+
156+
const outcome = await runEnrichment(
157+
config([
158+
prov('a', {
159+
projectFailure: (failure) => {
160+
if (
161+
typeof failure.output === 'object' &&
162+
failure.output !== null &&
163+
'data' in failure.output &&
164+
typeof failure.output.data === 'object' &&
165+
failure.output.data !== null &&
166+
'error_code' in failure.output.data &&
167+
failure.output.data.error_code === 'NO_MATCH'
168+
) {
169+
return { status: 'no_match' }
170+
}
171+
return projectEnrichmentProviderFailure(failure)
172+
},
173+
}),
174+
prov('b'),
175+
]),
176+
{},
177+
ctx
178+
)
179+
180+
expect(outcome.result).toEqual({ email: 'j@acme.com' })
181+
expect(outcome.error).toBeNull()
182+
expect(outcome.detail.providers.map((p) => p.status)).toEqual(['no_match', 'matched'])
183+
expect(mockExecuteTool).toHaveBeenCalledTimes(2)
184+
})
185+
186+
it('keeps non-miss provider errors as errors', async () => {
187+
mockExecuteTool.mockResolvedValue({
188+
success: false,
189+
error: 'INVALID_API_KEY',
190+
output: { status: 400, data: { error: true, error_code: 'INVALID_API_KEY' } },
191+
})
192+
193+
const outcome = await runEnrichment(
194+
config([
195+
prov('a', {
196+
projectFailure: (failure) => {
197+
if (
198+
typeof failure.output === 'object' &&
199+
failure.output !== null &&
200+
'data' in failure.output &&
201+
typeof failure.output.data === 'object' &&
202+
failure.output.data !== null &&
203+
'error_code' in failure.output.data &&
204+
failure.output.data.error_code === 'NO_MATCH'
205+
) {
206+
return { status: 'no_match' }
207+
}
208+
return projectEnrichmentProviderFailure(failure)
209+
},
210+
}),
211+
]),
212+
{},
213+
ctx
214+
)
215+
216+
expect(outcome.error).toBe('INVALID_API_KEY')
217+
expect(outcome.detail.providers.map((p) => p.status)).toEqual(['error'])
218+
})
219+
141220
it('skippedEnrichmentDetail marks every provider skipped without running', () => {
142221
const detail = skippedEnrichmentDetail(config([prov('a'), prov('b')]))
143222
expect(detail.matchedProvider).toBeNull()

apps/sim/enrichments/run.ts

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -119,13 +119,11 @@ export async function runEnrichment(
119119
}
120120
)
121121
if (!response.success) {
122-
// A 404 means the provider simply has no record for these inputs — a
123-
// clean no-match, not an infra failure. Fall through to the next
124-
// provider without counting it as an error (so the cell shows "Not
125-
// found" rather than an error). Other statuses (auth, rate-limit, 5xx)
126-
// are real errors and propagate.
127-
const status = (response.output as { status?: unknown } | undefined)?.status
128-
if (status === 404) {
122+
const projection = provider.projectFailure({
123+
error: response.error ?? `${provider.toolId} failed`,
124+
output: response.output,
125+
})
126+
if (projection.status === 'no_match') {
129127
providers.push({
130128
id: provider.id,
131129
label: provider.label,
@@ -137,7 +135,7 @@ export async function runEnrichment(
137135
})
138136
continue
139137
}
140-
throw new Error(response.error ?? `${provider.toolId} failed`)
138+
throw new Error(projection.error)
141139
}
142140
const providerCost = readCost(response.output)
143141
cost += providerCost

apps/sim/enrichments/types.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,17 @@ export interface EnrichmentRunContext {
3636
resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry
3737
}
3838

39+
/** Failed tool result projected into the enrichment provider boundary. */
40+
export interface EnrichmentProviderFailure {
41+
error: string
42+
output: unknown
43+
}
44+
45+
/** Normalized result of projecting a failed provider tool call. */
46+
export type EnrichmentProviderFailureProjection =
47+
| { status: 'no_match' }
48+
| { status: 'error'; error: string }
49+
3950
/**
4051
* One data source an enrichment can try, described as plain data so the catalog
4152
* (which the table UI imports for metadata) never pulls in server-only tool
@@ -55,6 +66,11 @@ export interface EnrichmentProvider {
5566
* inputs to run this provider (cascade falls through to the next).
5667
*/
5768
buildParams: (inputs: Record<string, unknown>) => Record<string, unknown> | null
69+
/**
70+
* Projects a failed tool call into the provider-neutral cascade outcome.
71+
* `toolProvider` supplies the standard HTTP projection unless overridden.
72+
*/
73+
projectFailure: (failure: EnrichmentProviderFailure) => EnrichmentProviderFailureProjection
5874
/**
5975
* Maps the tool's output to `{ [outputId]: value }`, or `null` for no result.
6076
* An empty/`null` result falls through to the next provider.

apps/sim/enrichments/work-email/work-email.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,21 @@ describe('work-email enrichment cascade', () => {
6868
email: 'j@acme.com',
6969
})
7070
})
71+
72+
it('projects Prospeo NO_MATCH as a clean miss without hiding genuine errors', () => {
73+
expect(
74+
p.projectFailure({
75+
error: 'NO_MATCH',
76+
output: { status: 400, data: { error: true, error_code: 'NO_MATCH' } },
77+
})
78+
).toEqual({ status: 'no_match' })
79+
expect(
80+
p.projectFailure({
81+
error: 'INVALID_API_KEY',
82+
output: { status: 400, data: { error: true, error_code: 'INVALID_API_KEY' } },
83+
})
84+
).toEqual({ status: 'error', error: 'INVALID_API_KEY' })
85+
})
7186
})
7287

7388
describe('wiza (opportunistic)', () => {

apps/sim/enrichments/work-email/work-email.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Mail } from '@sim/emcn/icons'
22
import { filterUndefined } from '@sim/utils/object'
3+
import { projectProspeoEnrichmentFailure } from '@/enrichments/provider-failures/prospeo'
34
import { normalizeDomain, splitName, str, toolProvider } from '@/enrichments/providers'
45
import type { EnrichmentConfig } from '@/enrichments/types'
56

@@ -86,6 +87,7 @@ export const workEmailEnrichment: EnrichmentConfig = {
8687
company_website: companyWebsite || undefined,
8788
})
8889
},
90+
projectFailure: projectProspeoEnrichmentFailure,
8991
mapOutput: (output) => {
9092
const person = output.person as Record<string, unknown> | undefined
9193
const emailObj = person?.email as Record<string, unknown> | undefined

0 commit comments

Comments
 (0)