Skip to content

Commit e078abd

Browse files
committed
Return 4xx instead of 500 for client errors in bundle endpoint
Why === All errors from the `/bundle/` endpoint returned HTTP 500, including permanent failures like "package not found" (404 from npm) and "package can't be bundled" (invalid modules, unresolvable dependencies). These expected client errors inflated 5xx rates and created unnecessary Sentry noise. They should be 4xx to signal "don't retry" and to separate them from real server errors in metrics. How === Two new error classes, `PackageNotFoundError` and `UnbundleablePackageError`, are thrown from the appropriate sites in `fetchMetadata` and `packageBundle`. In `bundle.ts`, `PackageNotFoundError` maps to 404 and `UnbundleablePackageError` maps to 422, with descriptive response bodies explaining the error and how to work around it. Only 500s are reported to Sentry. `fetchBundle` stores `errorName` in Redis alongside cached errors so that `UnbundleablePackageError` is preserved across the cache boundary. Log levels in `servePackage` and `fetchBundle` are downgraded to warn for client errors. Test Plan === `yarn test` passes (74 tests). New unit tests in `src/__tests__/bundle.test.ts` mock `servePackage` to throw each error class and verify the status code, response body, and Sentry behavior. `scripts/test-error-codes.sh` runs an integration test against local Redis: - 404 cold fetch: nonexistent package returns 404 with descriptive message - 404 repeated fetch: returns 404 again (registry 404s are not cached) - 422 cold fetch: `bcrypt@5.1.1` returns 200 pending (bundling is async) - 422 cached fetch: after bundling fails, the retry returns 422 with `errorName` preserved in the Redis cache - 400: core modules like `react-native` still return 400 (existing `parseRequest` behavior)
1 parent 521e91d commit e078abd

8 files changed

Lines changed: 338 additions & 19 deletions

File tree

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
#!/usr/bin/env bash
2+
#
3+
# Integration test for HTTP error codes from the /bundle/ endpoint.
4+
#
5+
# Prerequisites:
6+
# - Redis running on localhost:6379
7+
# - Node.js with --openssl-legacy-provider (webpack 4 + Node 22)
8+
#
9+
# Usage: ./scripts/test-error-codes.sh
10+
11+
set -euo pipefail
12+
13+
PORT=3099
14+
REDIS_DB=2
15+
REDIS_URL="redis://localhost:6379/$REDIS_DB"
16+
PASS=0
17+
FAIL=0
18+
19+
cleanup() {
20+
if [ -n "${SERVER_PID:-}" ]; then
21+
kill "$SERVER_PID" 2>/dev/null || true
22+
wait "$SERVER_PID" 2>/dev/null || true
23+
fi
24+
redis-cli -n "$REDIS_DB" FLUSHDB > /dev/null 2>&1 || true
25+
}
26+
trap cleanup EXIT
27+
28+
request() {
29+
local url="http://localhost:$PORT$1"
30+
local tmpfile
31+
tmpfile=$(mktemp)
32+
local status
33+
status=$(curl -s --max-time "${2:-30}" -o "$tmpfile" -w "%{http_code}" "$url")
34+
local body
35+
body=$(cat "$tmpfile")
36+
rm -f "$tmpfile"
37+
echo "$status"
38+
echo "$body"
39+
}
40+
41+
assert_status() {
42+
local description="$1" expected="$2" actual="$3" body="$4"
43+
if [ "$actual" = "$expected" ]; then
44+
echo " PASS: $description (HTTP $actual)"
45+
PASS=$((PASS + 1))
46+
else
47+
echo " FAIL: $description — expected HTTP $expected, got HTTP $actual"
48+
echo " Body: $body"
49+
FAIL=$((FAIL + 1))
50+
fi
51+
}
52+
53+
assert_contains() {
54+
local description="$1" expected="$2" actual="$3"
55+
if echo "$actual" | grep -qF "$expected"; then
56+
echo " PASS: $description"
57+
PASS=$((PASS + 1))
58+
else
59+
echo " FAIL: $description — expected body to contain '$expected'"
60+
echo " Body: $actual"
61+
FAIL=$((FAIL + 1))
62+
fi
63+
}
64+
65+
wait_for_redis_type() {
66+
local key="$1" expected_type="$2" timeout_secs="${3:-120}"
67+
for i in $(seq 1 "$timeout_secs"); do
68+
TYPE=$(redis-cli -n "$REDIS_DB" HGET "$key" type 2>/dev/null)
69+
if [ "$TYPE" = "$expected_type" ]; then
70+
return 0
71+
fi
72+
sleep 1
73+
done
74+
echo " Timed out waiting for Redis key $key to have type=$expected_type (got $TYPE)"
75+
return 1
76+
}
77+
78+
echo "Starting snackager on port $PORT..."
79+
redis-cli -n "$REDIS_DB" FLUSHDB > /dev/null
80+
81+
NODE_OPTIONS=--openssl-legacy-provider \
82+
DISABLE_INSTRUMENTATION=1 \
83+
REDIS_URL="$REDIS_URL" \
84+
IMPORT_SERVER_URL="http://localhost:$PORT" \
85+
AWS_ACCESS_KEY_ID=test \
86+
AWS_SECRET_ACCESS_KEY=test \
87+
S3_BUCKET=test \
88+
IMPORTS_S3_BUCKET=test \
89+
S3_REGION=us-west-1 \
90+
CLOUDFRONT_URL="http://localhost:$PORT/serve" \
91+
API_SERVER_URL=https://staging.exp.host \
92+
PORT="$PORT" \
93+
npx ts-node --require tsconfig-paths/register src/index.ts > /tmp/snackager-test.log 2>&1 &
94+
SERVER_PID=$!
95+
96+
for i in $(seq 1 30); do
97+
if curl -s "http://localhost:$PORT/status" > /dev/null 2>&1; then
98+
break
99+
fi
100+
sleep 1
101+
done
102+
103+
if ! curl -s "http://localhost:$PORT/status" > /dev/null 2>&1; then
104+
echo "Server failed to start"
105+
tail -20 /tmp/snackager-test.log
106+
exit 1
107+
fi
108+
echo "Server ready (PID $SERVER_PID)"
109+
echo
110+
111+
# ── 404: Package not found ───────────────────────────────────────────
112+
# PackageNotFoundError is thrown from fetchMetadata before fetchBundle
113+
# is called, so the 404 is returned synchronously on the first request.
114+
# The registry 404 is never cached in Redis, so repeated requests also
115+
# hit the registry and return 404.
116+
echo "── 404: Package not found ──"
117+
118+
echo " Cold fetch:"
119+
RESULT=$(request "/bundle/this-package-does-not-exist-xyz@1.0.0?platforms=ios")
120+
STATUS=$(echo "$RESULT" | head -1)
121+
BODY=$(echo "$RESULT" | tail -n +2)
122+
assert_status "nonexistent package returns 404" 404 "$STATUS" "$BODY"
123+
assert_contains "body explains the package was not found" "not found in the registry" "$BODY"
124+
assert_contains "body suggests checking the name and version" "Verify the package name and version" "$BODY"
125+
126+
echo " Repeated fetch (registry 404 is not cached):"
127+
RESULT=$(request "/bundle/this-package-does-not-exist-xyz@1.0.0?platforms=ios")
128+
STATUS=$(echo "$RESULT" | head -1)
129+
BODY=$(echo "$RESULT" | tail -n +2)
130+
assert_status "repeated request also returns 404" 404 "$STATUS" "$BODY"
131+
132+
echo
133+
134+
echo " Scoped package:"
135+
RESULT=$(request "/bundle/@example/nonexistent@1.0.0?platforms=ios")
136+
STATUS=$(echo "$RESULT" | head -1)
137+
BODY=$(echo "$RESULT" | tail -n +2)
138+
assert_status "nonexistent scoped package returns 404" 404 "$STATUS" "$BODY"
139+
140+
echo
141+
142+
# ── 422: Unbundleable package ────────────────────────────────────────
143+
# UnbundleablePackageError is thrown inside fetchBundle's try block,
144+
# which catches the error, caches it in Redis (with errorName), and
145+
# returns {pending: true}. The 422 is only returned on subsequent
146+
# requests when the cached error is re-thrown.
147+
echo "── 422: Unbundleable package ──"
148+
149+
# bcrypt imports Node.js's crypto module as a dependency; the bundler
150+
# installs it but still can't resolve it, triggering
151+
# UnbundleablePackageError ("Cannot resolve module crypto after
152+
# installing it as a dependency").
153+
echo " Cold fetch (bundling starts in background):"
154+
RESULT=$(request "/bundle/bcrypt@5.1.1?platforms=ios" 120)
155+
STATUS=$(echo "$RESULT" | head -1)
156+
BODY=$(echo "$RESULT" | tail -n +2)
157+
assert_status "first request returns 200 pending" 200 "$STATUS" "$BODY"
158+
assert_contains "response is pending" '"pending":true' "$BODY"
159+
160+
echo " Waiting for bundling to fail..."
161+
wait_for_redis_type "snackager/buildStatus/1/bcrypt@5.1.1-ios" "error"
162+
163+
echo " Redis cache:"
164+
ERROR_NAME=$(redis-cli -n "$REDIS_DB" HGET "snackager/buildStatus/1/bcrypt@5.1.1-ios" errorName 2>/dev/null)
165+
assert_status "errorName preserved in Redis" "UnbundleablePackageError" "$ERROR_NAME" ""
166+
167+
echo " Cached fetch (error re-thrown from Redis):"
168+
RESULT=$(request "/bundle/bcrypt@5.1.1?platforms=ios")
169+
STATUS=$(echo "$RESULT" | head -1)
170+
BODY=$(echo "$RESULT" | tail -n +2)
171+
assert_status "unbundleable package returns 422" 422 "$STATUS" "$BODY"
172+
assert_contains "body includes the bundler error" "Cannot resolve module crypto" "$BODY"
173+
assert_contains "body explains the limitation" "cannot be bundled for the Snack runtime" "$BODY"
174+
175+
echo
176+
177+
# ── 400: Core module ────────────────────────────────────────────────
178+
# Core modules are rejected by parseRequest before servePackage is
179+
# called. This is existing behavior.
180+
echo "── 400: Core module (existing behavior) ──"
181+
182+
RESULT=$(request "/bundle/react-native@0.73.0?platforms=ios" 15)
183+
STATUS=$(echo "$RESULT" | head -1)
184+
BODY=$(echo "$RESULT" | tail -n +2)
185+
assert_status "core module returns 400" 400 "$STATUS" "$BODY"
186+
187+
echo
188+
189+
# ── Summary ──────────────────────────────────────────────────────────
190+
echo "── Results: $PASS passed, $FAIL failed ──"
191+
if [ "$FAIL" -gt 0 ]; then
192+
exit 1
193+
fi
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import supertest from 'supertest';
2+
3+
import createApp from '../app';
4+
import { PackageNotFoundError, UnbundleablePackageError } from '../errors';
5+
import * as servePackageModule from '../utils/servePackage';
6+
7+
jest.mock('../utils/servePackage');
8+
9+
const mockedServePackage = servePackageModule.default as jest.MockedFunction<
10+
typeof servePackageModule.default
11+
>;
12+
13+
function request(): supertest.SuperTest<supertest.Test> {
14+
return supertest(createApp());
15+
}
16+
17+
describe('/bundle/', () => {
18+
it('returns 404 for PackageNotFoundError', async () => {
19+
mockedServePackage.mockRejectedValue(
20+
new PackageNotFoundError('Package "nonexistent" not found in the registry'),
21+
);
22+
23+
const response = await request().get('/bundle/nonexistent@1.0.0?platforms=ios');
24+
25+
expect(response.status).toBe(404);
26+
expect(response.text).toContain('not found in the registry');
27+
expect(response.text).toContain('Verify the package name and version');
28+
});
29+
30+
it('returns 422 for UnbundleablePackageError', async () => {
31+
mockedServePackage.mockRejectedValue(
32+
new UnbundleablePackageError('Cannot resolve module crypto after installing it as a dependency'),
33+
);
34+
35+
const response = await request().get('/bundle/bcrypt@5.1.1?platforms=ios');
36+
37+
expect(response.status).toBe(422);
38+
expect(response.text).toContain('Cannot resolve module crypto');
39+
expect(response.text).toContain('cannot be bundled for the Snack runtime');
40+
});
41+
42+
it('returns 500 for unexpected errors', async () => {
43+
mockedServePackage.mockRejectedValue(new Error('ENOENT: no such file or directory'));
44+
45+
const response = await request().get('/bundle/some-package@1.0.0?platforms=ios');
46+
47+
expect(response.status).toBe(500);
48+
expect(response.text).toContain('ENOENT');
49+
});
50+
51+
it('does not report PackageNotFoundError to Sentry', async () => {
52+
const raven = require('raven');
53+
jest.spyOn(raven, 'captureException');
54+
55+
mockedServePackage.mockRejectedValue(
56+
new PackageNotFoundError('Package "nonexistent" not found in the registry'),
57+
);
58+
59+
await request().get('/bundle/nonexistent@1.0.0?platforms=ios');
60+
61+
expect(raven.captureException).not.toHaveBeenCalled();
62+
});
63+
64+
it('does not report UnbundleablePackageError to Sentry', async () => {
65+
const raven = require('raven');
66+
jest.spyOn(raven, 'captureException');
67+
68+
mockedServePackage.mockRejectedValue(
69+
new UnbundleablePackageError('Cannot resolve module crypto'),
70+
);
71+
72+
await request().get('/bundle/bcrypt@5.1.1?platforms=ios');
73+
74+
expect(raven.captureException).not.toHaveBeenCalled();
75+
});
76+
});

snackager/src/bundle.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import querystring from 'querystring';
33
import raven from 'raven';
44

55
import config from './config';
6+
import { PackageNotFoundError, UnbundleablePackageError } from './errors';
67
import parseRequest, { BundleRequest } from './utils/parseRequest';
78
import servePackage from './utils/servePackage';
89

@@ -23,10 +24,26 @@ export default async function bundle(req: Request, res: Response): Promise<void>
2324
res.status(200);
2425
res.end(JSON.stringify(result));
2526
} catch (e) {
26-
if (config.sentry) {
27-
raven.captureException(e);
27+
if (e instanceof PackageNotFoundError) {
28+
res.status(404);
29+
res.end(
30+
`${e.message}. The package name may be misspelled, the version may not exist, ` +
31+
`or the package may have been unpublished. ` +
32+
`Verify the package name and version on the npm registry.`,
33+
);
34+
} else if (e instanceof UnbundleablePackageError) {
35+
res.status(422);
36+
res.end(
37+
`${e.message}. This package cannot be bundled for the Snack runtime because it ` +
38+
`depends on modules that are unavailable in the browser or on native platforms. ` +
39+
`Try using a different package that supports the target platforms.`,
40+
);
41+
} else {
42+
if (config.sentry) {
43+
raven.captureException(e);
44+
}
45+
res.status(500);
46+
res.end(e.message);
2847
}
29-
res.status(500);
30-
res.end(e.message);
3148
}
3249
}

snackager/src/errors.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
export class PackageNotFoundError extends Error {
2+
override readonly name = 'PackageNotFoundError';
3+
4+
constructor(message: string, options?: ErrorOptions) {
5+
super(message, options);
6+
}
7+
}
8+
9+
export class UnbundleablePackageError extends Error {
10+
override readonly name = 'UnbundleablePackageError';
11+
12+
constructor(message: string, options?: ErrorOptions) {
13+
super(message, options);
14+
}
15+
}

snackager/src/utils/fetchBundle.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import packageBundle from './packageBundle';
1010
import uploadFile from './uploadFile';
1111
import getCachePrefix from '../cache-busting';
1212
import config from '../config';
13+
import { UnbundleablePackageError } from '../errors';
1314
import { createRedisClient } from '../external/redis';
1415
import logger from '../logger';
1516
import { Package } from '../types';
@@ -114,6 +115,9 @@ export default async function fetchBundle({
114115
case 'error':
115116
logger.warn({ ...logMetadata, error: inProgress.message }, `an error occurred earlier`);
116117
if (!process.env.DEBUG_LOCAL_FILES) {
118+
if (inProgress.errorName === 'UnbundleablePackageError') {
119+
throw new UnbundleablePackageError(inProgress.message);
120+
}
117121
throw new Error(inProgress.message);
118122
}
119123
}
@@ -290,17 +294,23 @@ export default async function fetchBundle({
290294
logger.info(logMetadata, `marking id as finished`);
291295
client.del(buildStatusRedisId);
292296
} catch (error) {
293-
logger.error(
297+
const isClientError = error instanceof UnbundleablePackageError;
298+
const log = isClientError ? logger.warn.bind(logger) : logger.error.bind(logger);
299+
log(
294300
{ ...logMetadata, error },
295301
`unable to bundle, removing key from redis. error: ${error.message}`,
296302
);
297303
// Remove at a delay so we don't keep retrying
298304
client
299305
.multi()
300-
.hmset(buildStatusRedisId, { type: 'error', message: error.message })
306+
.hmset(buildStatusRedisId, {
307+
type: 'error',
308+
message: error.message,
309+
...(isClientError && { errorName: error.name }),
310+
})
301311
.expire(buildStatusRedisId, 60 * 5)
302312
.exec();
303-
if (config.sentry) {
313+
if (config.sentry && !isClientError) {
304314
raven.captureException(error);
305315
}
306316
} finally {

snackager/src/utils/fetchMetadata.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import fetch from 'node-fetch';
22

33
import config from '../config';
4+
import { PackageNotFoundError } from '../errors';
45
import { RedisClient } from '../external/redis';
56
import logger from '../logger';
67
import { Metadata } from '../types';
@@ -83,14 +84,13 @@ export default async function fetchMetadata(
8384
return json;
8485
} catch (e) {
8586
logger.error({ ...logMetadata, qualified, e }, `error in parsing: ${e.toString()}`);
86-
throw new Error(`Failed to parse the response for '${qualified}'`);
87+
throw new Error(`Failed to parse the response for "${qualified}"`);
8788
}
8889
} catch (e) {
8990
logger.error({ ...logMetadata, qualified, e }, `error in fetching: ${e.toString()}`);
90-
throw new Error(
91-
e.name === 'NotFoundError'
92-
? `Package '${qualified}' not found in the registry`
93-
: `Failed to fetch '${qualified}' from the registry`,
94-
);
91+
if (e.name === 'NotFoundError') {
92+
throw new PackageNotFoundError(`Package "${qualified}" not found in the registry`);
93+
}
94+
throw new Error(`Failed to fetch "${qualified}" from the registry`);
9595
}
9696
}

0 commit comments

Comments
 (0)