Skip to content

Commit d5918a2

Browse files
authored
feat(growth): explain company capture failures (#1015)
1 parent 30cb1d8 commit d5918a2

5 files changed

Lines changed: 279 additions & 10 deletions

File tree

apps/growth-research/README.md

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,19 @@ npx tsx apps/growth-research/scripts/research-pilot.mts acquire --output /absolu
1717
```
1818

1919
These commands return UUIDs for immutable JSON files in the selected output directory.
20-
Acquisition records include complete, partial, empty and failed outcomes. The existing
21-
fetcher can skip unusable pages, so missing paths have an unknown reason; redirects may
22-
make the original path indeterminate. Review the captured corpus before model calls:
20+
Acquisition records include complete, partial, empty and failed outcomes. Each capture's
21+
`pageDiagnostics` records the original requested path, a bounded outcome code, HTTP
22+
status and known byte count when available. Outcomes distinguish capture, access denial
23+
(403), rate limiting (429), other HTTP failures, oversized pages, request timeout,
24+
transport failure, rejected redirects, missing redirect locations, exhausted redirect
25+
budget and security rejection. Diagnostics emitted before a security rejection remain
26+
in the failed capture; caller cancellation still rejects acquisition. Diagnostics contain
27+
no response bodies, exception messages or redirect URLs. `access_denied` records HTTP
28+
403; it does not prove bot detection. Missing diagnostic entries can mean a page was
29+
not attempted or an older injected capture function did not support diagnostics. The
30+
250 KiB page limit, five-second timeout, three-total-redirect budget, exact-host redirect
31+
policy and SSRF controls are unchanged. The existing unavailable-path summary uses final URLs and can
32+
remain indeterminate after redirects. Review the captured corpus before model calls:
2333
remove personal biography/contact snippets, retain empty cases and failures, and fill
2434
expected claims/unknowns from the actual captured evidence. Save the reviewed corpus
2535
under a new name/version. Acquisition is preparation, not a human quality label.

apps/growth-research/src/pilot/acquisition.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import { fetchCompanyEvidence } from '../../../lifecycle/src/enrichment/company-fetch.js';
1+
import {
2+
fetchCompanyEvidence,
3+
type CompanyFetchOverrides,
4+
type CompanyPageDiagnostic,
5+
} from '../../../lifecycle/src/enrichment/company-fetch.js';
26
import type { CompanyPageEvidence } from '../../../lifecycle/src/enrichment/schema.js';
37

48
const expectedPaths = ['/', '/about', '/pricing'];
@@ -7,7 +11,8 @@ export async function acquireCompanies(
711
signal: AbortSignal,
812
capture: (
913
domain: string,
10-
signal: AbortSignal
14+
signal: AbortSignal,
15+
options?: Pick<CompanyFetchOverrides, 'onDiagnostic'>
1116
) => Promise<CompanyPageEvidence[]> = fetchCompanyEvidence
1217
) {
1318
if (
@@ -36,14 +41,18 @@ export async function acquireCompanies(
3641
reason: 'unavailable' | 'capture_failed' | null;
3742
redirectedPathsIndeterminate: boolean;
3843
filteredIdentityItems: number;
44+
pageDiagnostics: CompanyPageDiagnostic[];
3945
}[] = [];
4046
for (const [index, domain] of domains.entries()) {
4147
signal.throwIfAborted();
4248
const id = `public-${index + 1}`;
4349
let pages: CompanyPageEvidence[] = [],
4450
failed = false;
51+
const pageDiagnostics: CompanyPageDiagnostic[] = [];
4552
try {
46-
pages = await capture(domain, signal);
53+
pages = await capture(domain, signal, {
54+
onDiagnostic: (diagnostic) => pageDiagnostics.push(diagnostic),
55+
});
4756
} catch {
4857
signal.throwIfAborted();
4958
failed = true;
@@ -92,6 +101,7 @@ export async function acquireCompanies(
92101
(path) => !expectedPaths.includes(path)
93102
),
94103
filteredIdentityItems,
104+
pageDiagnostics,
95105
});
96106
}
97107
return {

apps/growth-research/test/pilot-acquisition.spec.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,34 @@
11
import { expect, it } from 'vitest';
22
import { acquireCompanies } from '../src/pilot/acquisition.js';
3+
// Exercise the same internal capture dependency used by pilot acquisition.
4+
// eslint-disable-next-line @nx/enforce-module-boundaries
5+
import { fetchCompanyEvidence } from '../../lifecycle/src/enrichment/company-fetch.js';
6+
7+
it('retains partial diagnostics when a later page rejects for security', async () => {
8+
const result = await acquireCompanies(
9+
['atlas.example'],
10+
new AbortController().signal,
11+
(domain, signal, options) =>
12+
fetchCompanyEvidence(domain, signal, {
13+
...options,
14+
resolve: async () => ['93.184.216.34'],
15+
fetch: async (url) =>
16+
url.pathname === '/'
17+
? new Response('<title>Atlas</title>')
18+
: new Response(null, {
19+
status: 302,
20+
headers: { location: 'https://unsafe.example/?secret=private' },
21+
}),
22+
})
23+
);
24+
expect(result.captures[0].status).toBe('failed');
25+
expect(result.cases[0].pages).toEqual([]);
26+
expect(result.captures[0].pageDiagnostics).toEqual([
27+
{ requestedPath: '/', outcome: 'captured', status: 200, bytes: 20 },
28+
{ requestedPath: '/about', outcome: 'redirect_rejected', status: 302 },
29+
]);
30+
expect(JSON.stringify(result)).not.toContain('private');
31+
});
332

433
it('keeps partial, empty, and failed company captures visible', async () => {
534
const result = await acquireCompanies(

apps/lifecycle/src/enrichment/company-fetch.spec.ts

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,157 @@ import {
99
fetchCompanyEvidence,
1010
resolveWithNodeDns,
1111
type CompanyFetchDependencies,
12+
type CompanyPageDiagnostic,
1213
type CompanyRequestInit,
1314
} from './company-fetch.js';
1415

1516
const NOW = new Date('2026-09-01T12:00:00.000Z');
1617

18+
describe('page diagnostics', () => {
19+
it.each([
20+
[403, 'access_denied'],
21+
[429, 'rate_limited'],
22+
[503, 'http_error'],
23+
] as const)(
24+
'reports HTTP %s without response content',
25+
async (status, outcome) => {
26+
const diagnostics: CompanyPageDiagnostic[] = [];
27+
await expect(
28+
fetchCompanyEvidence('example.com', new AbortController().signal, {
29+
...dependencies({
30+
fetch: async () => new Response('private body', { status }),
31+
}),
32+
onDiagnostic: (diagnostic) => diagnostics.push(diagnostic),
33+
})
34+
).resolves.toEqual([]);
35+
expect(diagnostics).toEqual(
36+
['/', '/about', '/pricing'].map((requestedPath) => ({
37+
requestedPath,
38+
outcome,
39+
status,
40+
}))
41+
);
42+
}
43+
);
44+
45+
it('records requested paths for redirected captures and isolates observer exceptions', async () => {
46+
const diagnostics: CompanyPageDiagnostic[] = [];
47+
const pages = await fetchCompanyEvidence(
48+
'example.com',
49+
new AbortController().signal,
50+
{
51+
...dependencies({
52+
fetch: async (url) =>
53+
url.pathname === '/about'
54+
? new Response(null, {
55+
status: 302,
56+
headers: { location: '/company?token=private' },
57+
})
58+
: okPage(),
59+
}),
60+
onDiagnostic: (diagnostic) => {
61+
diagnostics.push(diagnostic);
62+
throw new Error('observer');
63+
},
64+
}
65+
);
66+
expect(pages).toHaveLength(3);
67+
expect(diagnostics.map((d) => d.requestedPath)).toEqual([
68+
'/',
69+
'/about',
70+
'/pricing',
71+
]);
72+
expect(
73+
diagnostics.every(
74+
(d) =>
75+
d.outcome === 'captured' && d.status === 200 && (d.bytes ?? 0) > 0
76+
)
77+
).toBe(true);
78+
expect(JSON.stringify(diagnostics)).not.toContain('private');
79+
});
80+
81+
it.each([
82+
'missing_location',
83+
'redirect_limit',
84+
'redirect_rejected',
85+
'security_rejected',
86+
'page_too_large',
87+
'transport_failure',
88+
'timeout',
89+
] as const)('classifies %s without weakening policy', async (outcome) => {
90+
const diagnostics: CompanyPageDiagnostic[] = [];
91+
const ownTimeout = AbortSignal.abort(new Error('private timeout'));
92+
const operation = fetchCompanyEvidence(
93+
'example.com',
94+
new AbortController().signal,
95+
{
96+
...dependencies({
97+
...(outcome === 'timeout'
98+
? {
99+
createTimeoutSignal: () => ({
100+
signal: ownTimeout,
101+
clear: () => undefined,
102+
}),
103+
}
104+
: {}),
105+
resolve: async () => [
106+
outcome === 'security_rejected' ? '127.0.0.1' : '93.184.216.34',
107+
],
108+
fetch: async () => {
109+
if (outcome === 'transport_failure')
110+
throw new Error('private transport');
111+
if (outcome === 'page_too_large')
112+
return new Response('x', {
113+
headers: { 'content-length': '256001' },
114+
});
115+
return new Response(null, {
116+
status: 302,
117+
headers:
118+
outcome === 'missing_location'
119+
? {}
120+
: {
121+
location:
122+
outcome === 'redirect_rejected'
123+
? 'https://other.example/?secret=private'
124+
: '/loop',
125+
},
126+
});
127+
},
128+
}),
129+
onDiagnostic: (diagnostic) => {
130+
diagnostics.push(diagnostic);
131+
throw new Error('observer');
132+
},
133+
}
134+
);
135+
if (outcome === 'security_rejected' || outcome === 'redirect_rejected')
136+
await expect(operation).rejects.toThrow(/unsafe/iu);
137+
else await expect(operation).resolves.toEqual([]);
138+
expect(diagnostics[0]).toMatchObject({ requestedPath: '/', outcome });
139+
expect(JSON.stringify(diagnostics)).not.toContain('private');
140+
});
141+
142+
it('does not classify caller cancellation as a timeout or let an observer mask it', async () => {
143+
const controller = new AbortController();
144+
const reason = new Error('caller stopped');
145+
const observer = vi.fn(() => {
146+
throw new Error('observer');
147+
});
148+
await expect(
149+
fetchCompanyEvidence('example.com', controller.signal, {
150+
...dependencies({
151+
fetch: async () => {
152+
controller.abort(reason);
153+
throw reason;
154+
},
155+
}),
156+
onDiagnostic: observer,
157+
})
158+
).rejects.toBe(reason);
159+
expect(observer).not.toHaveBeenCalled();
160+
});
161+
});
162+
17163
function dependencies(
18164
overrides: Partial<CompanyFetchDependencies> = {}
19165
): CompanyFetchDependencies {

0 commit comments

Comments
 (0)