From e6899c63be8c369db80f81e9c35d9e2c24a69add Mon Sep 17 00:00:00 2001 From: EmersonBraun Date: Tue, 13 Oct 2026 12:30:23 -0300 Subject: [PATCH] fix(docs): tolerate npm downloads rate limits --- apps/docs-next/README.md | 4 +++ apps/docs-next/app/api/downloads/route.ts | 32 +++++++++++++++++------ readme-standard-v1.json | 2 +- 3 files changed, 29 insertions(+), 9 deletions(-) diff --git a/apps/docs-next/README.md b/apps/docs-next/README.md index 3019c984a..2277be65f 100644 --- a/apps/docs-next/README.md +++ b/apps/docs-next/README.md @@ -87,6 +87,10 @@ The public Cloudflare Worker uses the vinext build. Verify it locally with `pnpm --filter @agentskit/docs-next start:vinext`; deploy the merged revision with `pnpm --filter @agentskit/docs-next deploy:vinext` from the authorized Cloudflare account. +The unlisted `/downloads` page reads all 36 scoped packages from npm's public downloads API. +Its API route retries npm rate limits and caches a successful aggregate at the Cloudflare edge +for up to 24 hours. The displayed `through` date identifies the latest complete npm day. + ## Decision criteria for migration Compare against `apps/docs` (Docusaurus): diff --git a/apps/docs-next/app/api/downloads/route.ts b/apps/docs-next/app/api/downloads/route.ts index 083bf332f..882492fec 100644 --- a/apps/docs-next/app/api/downloads/route.ts +++ b/apps/docs-next/app/api/downloads/route.ts @@ -1,14 +1,25 @@ import { NextResponse } from 'next/server' -import { readJson } from '@agentskit/net' +import { fetchWithRetry, readJson } from '@agentskit/net' import { FIRST_PUBLISHED_DAY, PACKAGE_NAMES, dateChunks, type DailyCount, type DownloadsData, type PackageCounts, type PackageName, } from '@/lib/npm-downloads' const API = 'https://api.npmjs.org/downloads' +const CACHE_KEY = 'https://www.agentskit.io/api/downloads' +const CACHE_CONTROL = 'public, s-maxage=86400' + +function npmFetch(url: string): Promise { + return fetchWithRetry(url, {}, { + retries: 3, + minDelayMs: 750, + maxDelayMs: 3000, + maxRetryAfterMs: 15_000, + }) +} async function npmJson(url: string): Promise { - const response = await fetch(url, { next: { revalidate: 3600 } }) + const response = await npmFetch(url) if (!response.ok) throw new Error(`npm downloads API returned ${response.status}`) return readJson(response, { maxBytes: 64_000 }) } @@ -25,12 +36,10 @@ async function getPackage(name: PackageName, through: string): Promise() for (const chunk of dateChunks(FIRST_PUBLISHED_DAY, through)) { const url = `${API}/range/${chunk.start}:${chunk.end}/${name}` - const response = await fetch(url, { next: { revalidate: 3600 } }) + const response = await npmFetch(url) if (response.status === 404) { // npm has no download series until the first completed day after publication. - const registry = await fetch(`https://registry.npmjs.org/${encodeURIComponent(name)}`, { - next: { revalidate: 86400 }, - }) + const registry = await npmFetch(`https://registry.npmjs.org/${encodeURIComponent(name)}`) if (!registry.ok) throw new Error(`npm registry returned ${registry.status} for ${name}`) const metadata: unknown = await readJson(registry, { maxBytes: 4_000_000 }) const created = typeof metadata === 'object' && metadata !== null && 'time' in metadata && @@ -61,6 +70,11 @@ async function getPackage(name: PackageName, through: string): Promise undefined) + if (cached) return cached + const latest: unknown = await npmJson(`${API}/point/last-day`) if (typeof latest !== 'object' || latest === null || !('end' in latest) || typeof latest.end !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(latest.end)) { @@ -70,14 +84,16 @@ export async function GET() { const names = PACKAGE_NAMES.map((name) => `@agentskit/${name}` as PackageName) const packages: PackageCounts[] = new Array(names.length) let next = 0 - await Promise.all(Array.from({ length: 6 }, async () => { + await Promise.all(Array.from({ length: 2 }, async () => { while (next < names.length) { const index = next++ packages[index] = await getPackage(names[index], through) } })) const data: DownloadsData = { since: FIRST_PUBLISHED_DAY, through, packages } - return NextResponse.json(data, { headers: { 'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=3600' } }) + const response = NextResponse.json(data, { headers: { 'Cache-Control': CACHE_CONTROL } }) + await edgeCache?.put(cacheRequest, response.clone()).catch(() => undefined) + return response } catch (error) { console.error('Unable to load npm downloads:', error) return NextResponse.json({ error: 'npm download data is temporarily unavailable. Please retry.' }, { status: 502 }) diff --git a/readme-standard-v1.json b/readme-standard-v1.json index 8f89433c9..0d5ce6474 100644 --- a/readme-standard-v1.json +++ b/readme-standard-v1.json @@ -2388,7 +2388,7 @@ "ecosystem-claims.json", "scripts/readme-fixtures.test.mjs" ], - "sourceHash": "sha256:ba154b10083505e9fa48d20f056e7d5f74127d7c0af0663b963027a278e21a77" + "sourceHash": "sha256:8ebe774f74efc453b8a87cfb25ad6a6e7d609c46ae16ea0da30db1e9e9357a30" }, "exceptions": [] },