From e54b6a914cf3936da408b1ce615313ebdfa6b3fe Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 11 Aug 2026 13:26:52 +0200 Subject: [PATCH] fix(ata): fall back to the npm proxy when the CDN request fails outright (#10630) * fix(ata): fall back to the npm proxy when the CDN request fails outright * fix(ata): surface proxy failures and guard the body read too * docs(ata): state the proxy catch's constraint, not its history * fix(ata): log a failed proxy d.ts fetch, which callers discard --- frontend/src/lib/ata/apis.test.ts | 39 ++++++++++++++ frontend/src/lib/ata/apis.ts | 86 ++++++++++++++++++++----------- 2 files changed, 94 insertions(+), 31 deletions(-) create mode 100644 frontend/src/lib/ata/apis.test.ts diff --git a/frontend/src/lib/ata/apis.test.ts b/frontend/src/lib/ata/apis.test.ts new file mode 100644 index 0000000000..7450dc9bcd --- /dev/null +++ b/frontend/src/lib/ata/apis.test.ts @@ -0,0 +1,39 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { workspaceStore } from '$lib/stores' +import { getDTSFileForModuleWithVersion, getNPMVersionsForModule } from './apis' + +/** jsdelivr unreachable (blocked egress, DNS failure), backend proxy answering. */ +function mockUnreachableJsdelivr(proxyBody: string) { + return vi.fn(async (input: RequestInfo | URL) => { + const url = String(input) + if (url.includes('jsdelivr')) throw new TypeError('Failed to fetch') + if (url.includes('/npm_proxy/')) return new Response(proxyBody, { status: 200 }) + throw new Error(`unexpected request to ${url}`) + }) +} + +describe('ATA backend proxy fallback', () => { + afterEach(() => vi.unstubAllGlobals()) + + it('falls back to the proxy when the jsdelivr request rejects', async () => { + workspaceStore.set('test-workspace') + vi.stubGlobal( + 'fetch', + mockUnreachableJsdelivr('{"tags":{"latest":"1.0.0"},"versions":["1.0.0"]}') + ) + + const versions = await getNPMVersionsForModule('lodash', { usage: 0 }) + + expect(versions).not.toBeInstanceOf(Error) + expect((versions as { versions: string[] }).versions).toEqual(['1.0.0']) + }) + + it('falls back to the proxy for a d.ts when the jsdelivr request rejects', async () => { + workspaceStore.set('test-workspace') + vi.stubGlobal('fetch', mockUnreachableJsdelivr('declare const x: number')) + + const dts = await getDTSFileForModuleWithVersion('lodash', '1.0.0', '/index.d.ts') + + expect(dts).toBe('declare const x: number') + }) +}) diff --git a/frontend/src/lib/ata/apis.ts b/frontend/src/lib/ata/apis.ts index d54ca5d671..1bf95f0a18 100644 --- a/frontend/src/lib/ata/apis.ts +++ b/frontend/src/lib/ata/apis.ts @@ -25,7 +25,9 @@ const backendProxyApi = async (endpoint: string, resLimit: ResLimit): Promise const baseUrl = getBackendProxyUrl() const url = `${baseUrl}${endpoint}` - return limit(() => + // `await`, not a bare `return`: an async function adopts a returned promise after + // leaving the try block, so a rejection would escape the catch below. + return await limit(() => fetch(url, { credentials: 'include' }).then((res) => { if (res.ok) { return res.text().then((text) => { @@ -39,7 +41,10 @@ const backendProxyApi = async (endpoint: string, resLimit: ResLimit): Promise }) ) } catch (e) { - return new Error('Backend proxy not available') + // Keep the cause: where the proxy is the only reachable source, this is the sole + // report of a failed acquisition, since callers only test the result for `Error`. + console.warn(`Backend proxy request to ${endpoint} failed`, e) + return new Error(`Backend proxy not available: ${e}`) } } @@ -131,23 +136,37 @@ export const getDTSFileForModuleWithVersion = async ( ) => { // file comes with a prefix / const url = `https://cdn.jsdelivr.net/npm/${moduleName}@${version}${file}` - const res = await limit(() => fetch(url)) - if (res.ok) { - return res.text() - } else { - // Try backend proxy - console.log('jsdelivr failed for file', moduleName, version, file, 'trying backend proxy') - try { - const baseUrl = getBackendProxyUrl() - const proxyUrl = `${baseUrl}/file/${encodeURIComponent(moduleName)}/${encodeURIComponent(version)}${file}` - const proxyRes = await limit(() => fetch(proxyUrl, { credentials: 'include' })) - if (proxyRes.ok) { - return proxyRes.text() - } - } catch (e) { - console.log('Backend proxy failed for file', e) - } - return new Error('OK') + const res = await text(url) + if (typeof res === 'string') { + return res + } + + // Try backend proxy + console.log('jsdelivr failed for file', moduleName, version, file, 'trying backend proxy') + try { + const baseUrl = getBackendProxyUrl() + const proxyUrl = `${baseUrl}/file/${encodeURIComponent(moduleName)}/${encodeURIComponent(version)}${file}` + const proxied = await text(proxyUrl, { credentials: 'include' }) + // The callers in ata/index.ts log a fixed message and drop the value, so a cause + // left inside the returned `Error` is a cause nothing ever prints. + if (proxied instanceof Error) console.warn('Backend proxy failed for file', proxied) + return proxied + } catch (e) { + console.warn('Backend proxy failed for file', e) + return new Error(`Backend proxy not available: ${e}`) + } +} + +/** + * Reading the body can fail as readily as connecting, and both have to surface as a value + * rather than a rejection for the caller's fallback to run. + */ +async function text(url: string, init?: RequestInit): Promise { + try { + const res = await limit(() => fetch(url, init)) + return res.ok ? await res.text() : new Error(`${res.status} for ${url}`) + } catch (e) { + return new Error(`Request to ${url} failed: ${e}`) } } @@ -166,21 +185,26 @@ function api(url: string, resLimit: ResLimit, init?: RequestInit): Promise new Error('Exceeded limit of 100MB of data downloaded.')) + return Promise.resolve(new Error('Exceeded limit of 100MB of data downloaded.')) } + // Every caller decides what to do next by testing the resolved value for `Error`, so a + // rejection here is not an alternative signal: it skips the backend-proxy fallback and + // propagates out of type acquisition entirely. return limit(() => - fetch(url, init).then((res) => { - if (res.ok) { - return res.text().then((text) => { - resLimit.usage += text.length - console.log('resLimit', url, resLimit.usage) + fetch(url, init) + .then((res) => { + if (res.ok) { + return res.text().then((text) => { + resLimit.usage += text.length + console.log('resLimit', url, resLimit.usage) - return JSON.parse(text) as T - }) as Promise - } else { - return new Error('OK') - } - }) + return JSON.parse(text) as T + }) as Promise + } else { + return new Error('OK') + } + }) + .catch((e) => new Error(`Request to ${url} failed: ${e}`)) ) }