Skip to content

Commit 71bf891

Browse files
authored
fix: restore metadata request timeout (#289)
1 parent ab35aad commit 71bf891

10 files changed

Lines changed: 255 additions & 47 deletions

File tree

schema.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,12 @@
182182
"minimum": 1,
183183
"description": "Number of concurrent requests when resolving dependencies.",
184184
"default": 10
185+
},
186+
"requestTimeout": {
187+
"type": "integer",
188+
"minimum": 1,
189+
"description": "Request timeout in milliseconds when fetching package metadata.",
190+
"default": 5000
185191
}
186192
}
187193
}

src/cli.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ cli
4040
.option('--maturity-period [days]', 'wait period in days before upgrading to newly released packages (default: 7 when flag is used, 0 when not used)')
4141
.option('--maturity-period-exclude <deps>', 'dependencies to exclude from the maturity period filter')
4242
.option('--concurrency <requests>', 'number of concurrent requests when resolving dependencies', { default: 10 })
43+
.option('--request-timeout <ms>', 'request timeout in milliseconds when fetching package metadata', { default: 5000 })
4344
.action(async (mode: RangeMode | undefined, options: Partial<CheckOptions>) => {
4445
if (mode) {
4546
if (!MODE_CHOICES.includes(mode)) {

src/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export const DEFAULT_CHECK_OPTIONS: CheckOptions = {
3838
all: false,
3939
json: false,
4040
sort: 'diff-asc',
41+
requestTimeout: 5000,
4142
group: true,
4243
includeLocked: false,
4344
nodecompat: true,

src/io/resolves.ts

Lines changed: 39 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const debug = {
2121

2222
let cache: Record<string, { cacheTime: number, data: PackageData }> = {}
2323
let cacheChanged = false
24+
const inflightRequests = new Map<string, Promise<PackageData>>()
2425

2526
const cacheDir = resolve(os.tmpdir(), 'taze')
2627
const cachePath = resolve(cacheDir, 'cache.json')
@@ -58,7 +59,7 @@ export async function dumpCache() {
5859
}
5960
}
6061

61-
export async function getPackageData(name: string, protocol: Protocol = 'npm', cwd?: string): Promise<PackageData> {
62+
export async function getPackageData(name: string, protocol: Protocol = 'npm', cwd?: string, requestTimeout?: number): Promise<PackageData> {
6263
let error: any
6364
const cacheName = `${protocol}:${name}`
6465

@@ -72,25 +73,45 @@ export async function getPackageData(name: string, protocol: Protocol = 'npm', c
7273
}
7374
}
7475

75-
try {
76-
debug.resolve(`resolving ${cacheName}`)
77-
const data = protocol === 'jsr' ? await fetchJsrPackageMeta(name) : await fetchPackage(name, false, cwd)
76+
const inflightRequest = inflightRequests.get(cacheName)
7877

79-
if (data) {
80-
cache[cacheName] = { data, cacheTime: now() }
81-
cacheChanged = true
82-
return data
83-
}
84-
}
85-
catch (e) {
86-
error = e
78+
if (inflightRequest) {
79+
debug.cache(`in-flight hit for ${cacheName}`)
80+
return inflightRequest
8781
}
8882

89-
return {
90-
tags: {},
91-
versions: [],
92-
error: error?.statusCode?.toString() || error,
93-
deprecated: {},
83+
const request = (async () => {
84+
try {
85+
debug.resolve(`resolving ${cacheName}`)
86+
const data = protocol === 'jsr'
87+
? await fetchJsrPackageMeta(name, requestTimeout)
88+
: await fetchPackage(name, false, cwd, requestTimeout)
89+
90+
if (data) {
91+
cache[cacheName] = { data, cacheTime: now() }
92+
cacheChanged = true
93+
return data
94+
}
95+
}
96+
catch (e) {
97+
error = e
98+
}
99+
100+
return {
101+
tags: {},
102+
versions: [],
103+
error: error?.statusCode?.toString() || error,
104+
deprecated: {},
105+
}
106+
})()
107+
108+
inflightRequests.set(cacheName, request)
109+
110+
try {
111+
return await request
112+
}
113+
finally {
114+
inflightRequests.delete(cacheName)
94115
}
95116
}
96117

@@ -276,7 +297,7 @@ export async function resolveDependency(
276297
resolvedName = packages.pop() ?? dep.name
277298
}
278299

279-
const pkgData = await getPackageData(resolvedName, dep.protocol, options.cwd)
300+
const pkgData = await getPackageData(resolvedName, dep.protocol, options.cwd, options.requestTimeout)
280301
const { error, deprecated } = pkgData
281302

282303
dep.pkgData = pkgData

src/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,12 @@ export interface CheckOptions extends CommonOptions {
149149
* @default 10
150150
*/
151151
concurrency?: number
152+
/**
153+
* Request timeout in milliseconds when fetching package metadata
154+
*
155+
* @default 5000
156+
*/
157+
requestTimeout?: number
152158
/**
153159
* Group dependencies by source, e.g. dependencies, devDependencies, etc.
154160
*

src/utils/packument.ts

Lines changed: 42 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import process from 'node:process'
44
import { getVersions } from 'get-npm-meta'
55
import { fetch as ofetch } from 'ofetch'
66

7-
const TIMEOUT = 5000
7+
const DEFAULT_REQUEST_TIMEOUT = 5000
88
const JSR_API_REGISTRY = 'https://jsr.io/'
99
const USER_AGENT = `taze@npm node/${process.version}`
1010

@@ -48,37 +48,54 @@ const fetchWithUserAgent: typeof fetch = (input, init) => {
4848
return ofetch(input, { ...init, headers })
4949
}
5050

51-
export async function fetchPackage(spec: string, force: boolean = false, cwd?: string): Promise<PackageData> {
52-
const data = await Promise.race([
53-
getVersions(spec, {
54-
cwd,
55-
force,
56-
fetch: fetchWithUserAgent,
57-
metadata: true,
58-
throw: false,
59-
}),
60-
new Promise<Packument>(
61-
(_, reject) => setTimeout(() => reject(new Error(`Timeout requesting "${spec}"`)), TIMEOUT),
62-
),
63-
]) as PackageVersionsInfoWithMetadata
51+
function createTimeoutError(name: string) {
52+
return new Error(`Timeout requesting "${name}"`)
53+
}
54+
55+
async function withRequestTimeout<T>(name: string, timeout: number, run: (signal: AbortSignal) => Promise<T>) {
56+
const controller = new AbortController()
57+
const timeoutError = createTimeoutError(name)
58+
let timeoutId: ReturnType<typeof setTimeout> | undefined
59+
const timeoutPromise = new Promise<never>((_, reject) => {
60+
timeoutId = setTimeout(() => {
61+
reject(timeoutError)
62+
controller.abort(timeoutError)
63+
}, timeout)
64+
})
65+
66+
try {
67+
return await Promise.race([run(controller.signal), timeoutPromise])
68+
}
69+
finally {
70+
if (timeoutId)
71+
clearTimeout(timeoutId)
72+
73+
controller.abort(timeoutError)
74+
}
75+
}
76+
77+
export async function fetchPackage(spec: string, force: boolean = false, cwd?: string, requestTimeout: number = DEFAULT_REQUEST_TIMEOUT): Promise<PackageData> {
78+
const data = await withRequestTimeout(spec, requestTimeout, signal => getVersions(spec, {
79+
cwd,
80+
force,
81+
fetch: (input, init) => fetchWithUserAgent(input, { ...init, signal }),
82+
metadata: true,
83+
throw: false,
84+
})) as PackageVersionsInfoWithMetadata
6485

6586
if ('error' in data)
6687
throw new Error(`Failed to fetch package "${spec}": ${data.error}`)
6788

6889
return toPackageData(data)
6990
}
7091

71-
export async function fetchJsrPackageMeta(name: string): Promise<PackageData> {
72-
const meta = await Promise.race([
73-
fetchWithUserAgent(new URL(`${name}/meta.json`, JSR_API_REGISTRY), {
74-
headers: {
75-
accept: 'application/json',
76-
},
77-
}).then(r => r.json()),
78-
new Promise<JsrPackageMeta>(
79-
(_, reject) => setTimeout(() => reject(new Error(`Timeout requesting "${name}"`)), TIMEOUT),
80-
),
81-
]) as JsrPackageMeta
92+
export async function fetchJsrPackageMeta(name: string, requestTimeout: number = DEFAULT_REQUEST_TIMEOUT): Promise<PackageData> {
93+
const meta = await withRequestTimeout(name, requestTimeout, signal => fetchWithUserAgent(new URL(`${name}/meta.json`, JSR_API_REGISTRY), {
94+
signal,
95+
headers: {
96+
accept: 'application/json',
97+
},
98+
}).then(r => r.json())) as JsrPackageMeta
8299

83100
return {
84101
versions: Object.keys(meta.versions),

test/cli.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,12 @@ it('taze cli should accept --concurrency option', async () => {
1919
expect(proc.stderr).toBe('')
2020
expect(proc.exitCode).toBe(0)
2121
})
22+
23+
it('taze cli should accept --request-timeout option', async () => {
24+
const binPath = resolve(__dirname, '../bin/taze.mjs')
25+
26+
const proc = await exec(process.execPath, [binPath, '--request-timeout', '15000'], { throwOnError: false })
27+
28+
expect(proc.stderr).toBe('')
29+
expect(proc.exitCode).toBe(0)
30+
})

test/packument.test.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { afterEach, expect, it, vi } from 'vitest'
2+
import { fetchPackage } from '../src/utils/packument'
3+
4+
const { getVersionsMock, ofetchMock } = vi.hoisted(() => ({
5+
getVersionsMock: vi.fn(),
6+
ofetchMock: vi.fn(),
7+
}))
8+
9+
vi.mock('get-npm-meta', () => ({
10+
getVersions: getVersionsMock,
11+
}))
12+
13+
vi.mock('ofetch', () => ({
14+
fetch: ofetchMock,
15+
}))
16+
17+
afterEach(() => {
18+
vi.restoreAllMocks()
19+
vi.useRealTimers()
20+
getVersionsMock.mockReset()
21+
ofetchMock.mockReset()
22+
})
23+
24+
it('aborts npm metadata requests when timeout is exceeded', async () => {
25+
vi.useFakeTimers()
26+
27+
let aborted = false
28+
29+
ofetchMock.mockImplementation((_input, init) => new Promise((_, reject) => {
30+
const signal = init?.signal as AbortSignal | undefined
31+
32+
signal?.addEventListener('abort', () => {
33+
aborted = true
34+
reject(new DOMException('Aborted', 'AbortError'))
35+
})
36+
}))
37+
38+
getVersionsMock.mockImplementation((_spec, options) => options.fetch('https://registry.npmjs.org/drizzle-orm'))
39+
40+
const promise = fetchPackage('drizzle-orm', false, undefined, 25)
41+
const rejection = expect(promise).rejects.toThrow('Timeout requesting "drizzle-orm"')
42+
43+
await vi.advanceTimersByTimeAsync(25)
44+
45+
await rejection
46+
expect(aborted).toBe(true)
47+
})
48+
49+
it('times out promptly even when upstream ignores abort settlement', async () => {
50+
vi.useFakeTimers()
51+
52+
let aborted = false
53+
54+
ofetchMock.mockImplementation((_input, init) => new Promise(() => {
55+
const signal = init?.signal as AbortSignal | undefined
56+
57+
signal?.addEventListener('abort', () => {
58+
aborted = true
59+
})
60+
}))
61+
62+
getVersionsMock.mockImplementation((_spec, options) => {
63+
options.fetch('https://registry.npmjs.org/drizzle-orm')
64+
return new Promise(() => {})
65+
})
66+
67+
const promise = fetchPackage('drizzle-orm', false, undefined, 25)
68+
const rejection = expect(promise).rejects.toThrow('Timeout requesting "drizzle-orm"')
69+
70+
await vi.advanceTimersByTimeAsync(25)
71+
72+
await rejection
73+
expect(aborted).toBe(true)
74+
})
75+
76+
it('clears the timeout after a successful request', async () => {
77+
vi.useFakeTimers()
78+
79+
getVersionsMock.mockResolvedValue({
80+
distTags: { latest: '1.0.0' },
81+
timeCreated: '2024-01-01T00:00:00.000Z',
82+
timeModified: '2024-01-01T00:00:00.000Z',
83+
versionsMeta: {
84+
'1.0.0': {},
85+
},
86+
})
87+
88+
await expect(fetchPackage('drizzle-orm', false, undefined, 25)).resolves.toMatchObject({
89+
tags: { latest: '1.0.0' },
90+
versions: ['1.0.0'],
91+
})
92+
93+
expect(vi.getTimerCount()).toBe(0)
94+
})

test/resolves-cache.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import type { PackageData } from '../src/types'
2+
import { afterEach, expect, it, vi } from 'vitest'
3+
4+
const { fetchJsrPackageMetaMock, fetchPackageMock } = vi.hoisted(() => ({
5+
fetchJsrPackageMetaMock: vi.fn(),
6+
fetchPackageMock: vi.fn(),
7+
}))
8+
9+
vi.mock('../src/utils/packument.ts', () => ({
10+
fetchJsrPackageMeta: fetchJsrPackageMetaMock,
11+
fetchPackage: fetchPackageMock,
12+
}))
13+
14+
afterEach(() => {
15+
vi.restoreAllMocks()
16+
vi.resetModules()
17+
fetchJsrPackageMetaMock.mockReset()
18+
fetchPackageMock.mockReset()
19+
})
20+
21+
it('dedupes concurrent requests for the same package', async () => {
22+
const packageData: PackageData = {
23+
tags: { latest: '5.8.3' },
24+
versions: ['5.8.3'],
25+
}
26+
27+
let resolveFetch: ((value: PackageData) => void) | undefined
28+
const pendingFetch = new Promise<PackageData>((resolve) => {
29+
resolveFetch = resolve
30+
})
31+
32+
fetchPackageMock.mockReturnValue(pendingFetch)
33+
34+
const { getPackageData } = await import('../src/io/resolves')
35+
const firstRequest = getPackageData('typescript', 'npm', '/tmp', 15000)
36+
const secondRequest = getPackageData('typescript', 'npm', '/tmp', 15000)
37+
38+
expect(fetchPackageMock).toHaveBeenCalledTimes(1)
39+
expect(fetchPackageMock).toHaveBeenCalledWith('typescript', false, '/tmp', 15000)
40+
41+
resolveFetch?.(packageData)
42+
43+
await expect(Promise.all([firstRequest, secondRequest])).resolves.toStrictEqual([packageData, packageData])
44+
})

0 commit comments

Comments
 (0)