From c24cfb7602de22892aad58d99bac2f7bdff3189f Mon Sep 17 00:00:00 2001 From: johnnyreilly Date: Sun, 8 Feb 2026 09:44:25 +0000 Subject: [PATCH] fix: surfacing 429s as not found to users --- app/composables/npm/useNpmSearch.ts | 103 ++++++++++++++++++---------- app/pages/search.vue | 14 +++- i18n/locales/en.json | 1 + lunaria/files/en-GB.json | 1 + lunaria/files/en-US.json | 1 + 5 files changed, 79 insertions(+), 41 deletions(-) diff --git a/app/composables/npm/useNpmSearch.ts b/app/composables/npm/useNpmSearch.ts index 77fdf738a..af62c2a0a 100644 --- a/app/composables/npm/useNpmSearch.ts +++ b/app/composables/npm/useNpmSearch.ts @@ -65,6 +65,10 @@ export function useNpmSearch( const isLoadingMore = shallowRef(false) + // Track rate limit errors separately for better UX + // Using ref instead of shallowRef to ensure reactivity triggers properly + const isRateLimited = ref(false) + // Standard (non-incremental) search implementation let lastSearch: NpmSearchResponse | undefined = undefined @@ -74,13 +78,14 @@ export function useNpmSearch( const q = toValue(query) if (!q.trim()) { + isRateLimited.value = false return emptySearchResponse } const opts = toValue(options) // This only runs for initial load or query changes - // Reset cache for new query + // Reset cache for new query (but don't reset rate limit yet - only on success) cache.value = null const params = new URLSearchParams() @@ -88,20 +93,49 @@ export function useNpmSearch( // Use requested size for initial fetch params.set('size', String(opts.size ?? 25)) - if (q.length === 1) { - const encodedName = encodePackageName(q) - const [{ data: pkg, isStale }, { data: downloads }] = await Promise.all([ - $npmRegistry(`/${encodedName}`, { signal }), - $npmApi(`/downloads/point/last-week/${encodedName}`, { - signal, - }), - ]) - - if (!pkg) { - return emptySearchResponse + try { + if (q.length === 1) { + const encodedName = encodePackageName(q) + const [{ data: pkg, isStale }, { data: downloads }] = await Promise.all([ + $npmRegistry(`/${encodedName}`, { signal }), + $npmApi(`/downloads/point/last-week/${encodedName}`, { + signal, + }), + ]) + + if (!pkg) { + return emptySearchResponse + } + + const result = packumentToSearchResult(pkg, downloads?.downloads) + + // If query changed/outdated, return empty search response + if (q !== toValue(query)) { + return emptySearchResponse + } + + cache.value = { + query: q, + objects: [result], + total: 1, + } + + // Success - clear rate limit flag + isRateLimited.value = false + + return { + objects: [result], + total: 1, + isStale, + time: new Date().toISOString(), + } } - const result = packumentToSearchResult(pkg, downloads?.downloads) + const { data: response, isStale } = await $npmRegistry( + `/-/v1/search?${params.toString()}`, + { signal }, + 60, + ) // If query changed/outdated, return empty search response if (q !== toValue(query)) { @@ -110,36 +144,27 @@ export function useNpmSearch( cache.value = { query: q, - objects: [result], - total: 1, - } - - return { - objects: [result], - total: 1, - isStale, - time: new Date().toISOString(), + objects: response.objects, + total: response.total, } - } - const { data: response, isStale } = await $npmRegistry( - `/-/v1/search?${params.toString()}`, - { signal }, - 60, - ) + // Success - clear rate limit flag + isRateLimited.value = false - // If query changed/outdated, return empty search response - if (q !== toValue(query)) { - return emptySearchResponse - } + return { ...response, isStale } + } catch (error: unknown) { + // Detect rate limit errors. npm's 429 response doesn't include CORS headers, + // so the browser reports "Failed to fetch" instead of the actual status code. + const errorMessage = (error as { message?: string })?.message || String(error) + const isRateLimitError = + errorMessage.includes('Failed to fetch') || errorMessage.includes('429') - cache.value = { - query: q, - objects: response.objects, - total: response.total, + if (isRateLimitError) { + isRateLimited.value = true + return emptySearchResponse + } + throw error } - - return { ...response, isStale } }, { default: () => lastSearch || emptySearchResponse }, ) @@ -260,5 +285,7 @@ export function useNpmSearch( hasMore, /** Manually fetch more results up to target size (incremental mode only) */ fetchMore, + /** Whether the search was rate limited by npm (429 error) */ + isRateLimited: readonly(isRateLimited), } } diff --git a/app/pages/search.vue b/app/pages/search.vue index 8700b574f..173228ff1 100644 --- a/app/pages/search.vue +++ b/app/pages/search.vue @@ -72,6 +72,7 @@ const { isLoadingMore, hasMore, fetchMore, + isRateLimited, } = useNpmSearch(query, () => ({ size: requestedSize.value, incremental: true, @@ -706,8 +707,15 @@ defineOgImageComponent('Default', { + +
+

+ {{ $t('search.rate_limited') }} +

+
+ -
+