Skip to content

Commit 5da5aba

Browse files
committed
fix: drain packagist response bodies on error paths
Signed-off-by: anilb <epipav@gmail.com>
1 parent fde7e7e commit 5da5aba

2 files changed

Lines changed: 59 additions & 18 deletions

File tree

services/apps/packages_worker/src/packagist/__tests__/fetchPackage.test.ts

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
22

33
import { buildPackagistUserAgent, fetchPackagistP2, fetchPackagistStats } from '../fetchPackage'
44

5-
// Minimal Response stand-in for the global fetch mock.
5+
// Minimal Response stand-in for the global fetch mock. `body.cancel` is a spy so
6+
// error-path tests can assert the response body is drained before returning.
67
function fakeResponse(
78
status: number,
89
body?: unknown,
910
opts: { jsonThrows?: boolean; lastModified?: string } = {},
10-
): Response {
11+
): Response & { body: { cancel: ReturnType<typeof vi.fn> } } {
1112
return {
1213
status,
1314
ok: status >= 200 && status < 300,
@@ -19,7 +20,8 @@ function fakeResponse(
1920
if (opts.jsonThrows) throw new Error('bad json')
2021
return body
2122
},
22-
} as unknown as Response
23+
body: { cancel: vi.fn() },
24+
} as unknown as Response & { body: { cancel: ReturnType<typeof vi.fn> } }
2325
}
2426

2527
const validStats = { package: { name: 'monolog/monolog', downloads: { monthly: 5 } } }
@@ -65,28 +67,35 @@ describe('fetchPackagistStats', () => {
6567
expect(headers['User-Agent']).toMatch(/mailto=/)
6668
})
6769

68-
it('maps 404 → NOT_FOUND', async () => {
69-
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(fakeResponse(404)))
70+
it('maps 404 → NOT_FOUND and drains the response body', async () => {
71+
const res = fakeResponse(404)
72+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(res))
7073
expect(await fetchPackagistStats('gone/gone')).toMatchObject({
7174
kind: 'NOT_FOUND',
7275
statusCode: 404,
7376
})
77+
// an undrained body can pin the socket instead of returning it to the shared pool
78+
expect(res.body.cancel).toHaveBeenCalled()
7479
})
7580

76-
it('maps 429 → RATE_LIMIT', async () => {
77-
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(fakeResponse(429)))
81+
it('maps 429 → RATE_LIMIT and drains the response body', async () => {
82+
const res = fakeResponse(429)
83+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(res))
7884
expect(await fetchPackagistStats('busy/busy')).toMatchObject({
7985
kind: 'RATE_LIMIT',
8086
statusCode: 429,
8187
})
88+
expect(res.body.cancel).toHaveBeenCalled()
8289
})
8390

84-
it('maps other non-ok statuses (5xx) → TRANSIENT with the status code', async () => {
85-
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(fakeResponse(503)))
91+
it('maps other non-ok statuses (5xx) → TRANSIENT with the status code and drains the body', async () => {
92+
const res = fakeResponse(503)
93+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(res))
8694
expect(await fetchPackagistStats('flaky/flaky')).toMatchObject({
8795
kind: 'TRANSIENT',
8896
statusCode: 503,
8997
})
98+
expect(res.body.cancel).toHaveBeenCalled()
9099
})
91100

92101
it('maps a network rejection → TRANSIENT without a status code', async () => {
@@ -170,12 +179,16 @@ describe('fetchPackagistP2', () => {
170179
})
171180
})
172181

173-
it('maps 404 → NOT_FOUND and 429 → RATE_LIMIT', async () => {
174-
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(fakeResponse(404)))
182+
it('maps 404 → NOT_FOUND and 429 → RATE_LIMIT, draining the body each time', async () => {
183+
const notFound = fakeResponse(404)
184+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(notFound))
175185
expect(await fetchPackagistP2('gone/gone', null)).toMatchObject({ kind: 'NOT_FOUND' })
186+
expect(notFound.body.cancel).toHaveBeenCalled()
176187

177-
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(fakeResponse(429)))
188+
const rateLimited = fakeResponse(429)
189+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(rateLimited))
178190
expect(await fetchPackagistP2('busy/busy', null)).toMatchObject({ kind: 'RATE_LIMIT' })
191+
expect(rateLimited.body.cancel).toHaveBeenCalled()
179192
})
180193

181194
it('maps a payload missing the package key → MALFORMED', async () => {

services/apps/packages_worker/src/packagist/fetchPackage.ts

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,18 @@ export function describeFetchFailure(err: unknown): string {
3333
return detail ? `${String(err)} (cause: ${detail})` : String(err)
3434
}
3535

36+
// An error-path response whose body is never read can pin its socket instead of
37+
// returning it to packagistDispatcher's pool (capped at 10 connections per origin) —
38+
// canceling it releases the connection for reuse. Best-effort: a failure here must
39+
// never mask the real error already being returned.
40+
async function discardBody(res: Response): Promise<void> {
41+
try {
42+
await res.body?.cancel()
43+
} catch {
44+
// ignore
45+
}
46+
}
47+
3648
export async function fetchPackagistStats(name: string): Promise<PackagistStatsJson | FetchError> {
3749
const url = `https://packagist.org/packages/${name}.json`
3850
const abort = new AbortController()
@@ -55,10 +67,18 @@ export async function fetchPackagistStats(name: string): Promise<PackagistStatsJ
5567
return { kind: 'TRANSIENT', message: describeFetchFailure(err) }
5668
}
5769

58-
if (res.status === 404)
70+
if (res.status === 404) {
71+
await discardBody(res)
5972
return { kind: 'NOT_FOUND', message: `${name} not found`, statusCode: 404 }
60-
if (res.status === 429) return { kind: 'RATE_LIMIT', message: 'rate limited', statusCode: 429 }
61-
if (!res.ok) return { kind: 'TRANSIENT', message: `HTTP ${res.status}`, statusCode: res.status }
73+
}
74+
if (res.status === 429) {
75+
await discardBody(res)
76+
return { kind: 'RATE_LIMIT', message: 'rate limited', statusCode: 429 }
77+
}
78+
if (!res.ok) {
79+
await discardBody(res)
80+
return { kind: 'TRANSIENT', message: `HTTP ${res.status}`, statusCode: res.status }
81+
}
6282

6383
let json: unknown
6484
try {
@@ -113,10 +133,18 @@ export async function fetchPackagistP2(
113133
return { kind: 'NOT_MODIFIED' }
114134
}
115135

116-
if (res.status === 404)
136+
if (res.status === 404) {
137+
await discardBody(res)
117138
return { kind: 'NOT_FOUND', message: `${name} not found`, statusCode: 404 }
118-
if (res.status === 429) return { kind: 'RATE_LIMIT', message: 'rate limited', statusCode: 429 }
119-
if (!res.ok) return { kind: 'TRANSIENT', message: `HTTP ${res.status}`, statusCode: res.status }
139+
}
140+
if (res.status === 429) {
141+
await discardBody(res)
142+
return { kind: 'RATE_LIMIT', message: 'rate limited', statusCode: 429 }
143+
}
144+
if (!res.ok) {
145+
await discardBody(res)
146+
return { kind: 'TRANSIENT', message: `HTTP ${res.status}`, statusCode: res.status }
147+
}
120148

121149
let json: unknown
122150
try {

0 commit comments

Comments
 (0)