Skip to content

Commit 13ef534

Browse files
authored
fix: surfacing 429s as not found to users (#1200)
1 parent 5f9ef8f commit 13ef534

File tree

5 files changed

+79
-41
lines changed

5 files changed

+79
-41
lines changed

app/composables/npm/useNpmSearch.ts

Lines changed: 65 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ export function useNpmSearch(
6565

6666
const isLoadingMore = shallowRef(false)
6767

68+
// Track rate limit errors separately for better UX
69+
// Using ref instead of shallowRef to ensure reactivity triggers properly
70+
const isRateLimited = ref(false)
71+
6872
// Standard (non-incremental) search implementation
6973
let lastSearch: NpmSearchResponse | undefined = undefined
7074

@@ -74,34 +78,64 @@ export function useNpmSearch(
7478
const q = toValue(query)
7579

7680
if (!q.trim()) {
81+
isRateLimited.value = false
7782
return emptySearchResponse
7883
}
7984

8085
const opts = toValue(options)
8186

8287
// This only runs for initial load or query changes
83-
// Reset cache for new query
88+
// Reset cache for new query (but don't reset rate limit yet - only on success)
8489
cache.value = null
8590

8691
const params = new URLSearchParams()
8792
params.set('text', q)
8893
// Use requested size for initial fetch
8994
params.set('size', String(opts.size ?? 25))
9095

91-
if (q.length === 1) {
92-
const encodedName = encodePackageName(q)
93-
const [{ data: pkg, isStale }, { data: downloads }] = await Promise.all([
94-
$npmRegistry<Packument>(`/${encodedName}`, { signal }),
95-
$npmApi<NpmDownloadCount>(`/downloads/point/last-week/${encodedName}`, {
96-
signal,
97-
}),
98-
])
99-
100-
if (!pkg) {
101-
return emptySearchResponse
96+
try {
97+
if (q.length === 1) {
98+
const encodedName = encodePackageName(q)
99+
const [{ data: pkg, isStale }, { data: downloads }] = await Promise.all([
100+
$npmRegistry<Packument>(`/${encodedName}`, { signal }),
101+
$npmApi<NpmDownloadCount>(`/downloads/point/last-week/${encodedName}`, {
102+
signal,
103+
}),
104+
])
105+
106+
if (!pkg) {
107+
return emptySearchResponse
108+
}
109+
110+
const result = packumentToSearchResult(pkg, downloads?.downloads)
111+
112+
// If query changed/outdated, return empty search response
113+
if (q !== toValue(query)) {
114+
return emptySearchResponse
115+
}
116+
117+
cache.value = {
118+
query: q,
119+
objects: [result],
120+
total: 1,
121+
}
122+
123+
// Success - clear rate limit flag
124+
isRateLimited.value = false
125+
126+
return {
127+
objects: [result],
128+
total: 1,
129+
isStale,
130+
time: new Date().toISOString(),
131+
}
102132
}
103133

104-
const result = packumentToSearchResult(pkg, downloads?.downloads)
134+
const { data: response, isStale } = await $npmRegistry<NpmSearchResponse>(
135+
`/-/v1/search?${params.toString()}`,
136+
{ signal },
137+
60,
138+
)
105139

106140
// If query changed/outdated, return empty search response
107141
if (q !== toValue(query)) {
@@ -110,36 +144,27 @@ export function useNpmSearch(
110144

111145
cache.value = {
112146
query: q,
113-
objects: [result],
114-
total: 1,
115-
}
116-
117-
return {
118-
objects: [result],
119-
total: 1,
120-
isStale,
121-
time: new Date().toISOString(),
147+
objects: response.objects,
148+
total: response.total,
122149
}
123-
}
124150

125-
const { data: response, isStale } = await $npmRegistry<NpmSearchResponse>(
126-
`/-/v1/search?${params.toString()}`,
127-
{ signal },
128-
60,
129-
)
151+
// Success - clear rate limit flag
152+
isRateLimited.value = false
130153

131-
// If query changed/outdated, return empty search response
132-
if (q !== toValue(query)) {
133-
return emptySearchResponse
134-
}
154+
return { ...response, isStale }
155+
} catch (error: unknown) {
156+
// Detect rate limit errors. npm's 429 response doesn't include CORS headers,
157+
// so the browser reports "Failed to fetch" instead of the actual status code.
158+
const errorMessage = (error as { message?: string })?.message || String(error)
159+
const isRateLimitError =
160+
errorMessage.includes('Failed to fetch') || errorMessage.includes('429')
135161

136-
cache.value = {
137-
query: q,
138-
objects: response.objects,
139-
total: response.total,
162+
if (isRateLimitError) {
163+
isRateLimited.value = true
164+
return emptySearchResponse
165+
}
166+
throw error
140167
}
141-
142-
return { ...response, isStale }
143168
},
144169
{ default: () => lastSearch || emptySearchResponse },
145170
)
@@ -260,5 +285,7 @@ export function useNpmSearch(
260285
hasMore,
261286
/** Manually fetch more results up to target size (incremental mode only) */
262287
fetchMore,
288+
/** Whether the search was rate limited by npm (429 error) */
289+
isRateLimited: readonly(isRateLimited),
263290
}
264291
}

app/pages/search.vue

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ const {
7272
isLoadingMore,
7373
hasMore,
7474
fetchMore,
75+
isRateLimited,
7576
} = useNpmSearch(query, () => ({
7677
size: requestedSize.value,
7778
incremental: true,
@@ -706,8 +707,15 @@ defineOgImageComponent('Default', {
706707
</button>
707708
</div>
708709

710+
<!-- Rate limited by npm - check FIRST before showing any results -->
711+
<div v-if="isRateLimited" role="status" class="py-12">
712+
<p class="text-fg-muted font-mono mb-6 text-center">
713+
{{ $t('search.rate_limited') }}
714+
</p>
715+
</div>
716+
709717
<!-- Enhanced toolbar -->
710-
<div v-if="visibleResults.total > 0" class="mb-6">
718+
<div v-else-if="visibleResults.total > 0" class="mb-6">
711719
<PackageListToolbar
712720
:filters="filters"
713721
v-model:sort-option="sortOption"
@@ -805,7 +813,7 @@ defineOgImageComponent('Default', {
805813
</div>
806814

807815
<PackageList
808-
v-if="displayResults.length > 0"
816+
v-if="displayResults.length > 0 && !isRateLimited"
809817
:results="displayResults"
810818
:search-query="query"
811819
:filters="filters"
@@ -828,7 +836,7 @@ defineOgImageComponent('Default', {
828836

829837
<!-- Pagination controls -->
830838
<PaginationControls
831-
v-if="displayResults.length > 0"
839+
v-if="displayResults.length > 0 && !isRateLimited"
832840
v-model:mode="paginationMode"
833841
v-model:page-size="preferredPageSize"
834842
v-model:current-page="currentPage"

i18n/locales/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"found_packages": "No packages found | Found 1 package | Found {count} packages",
2626
"updating": "(updating...)",
2727
"no_results": "No packages found for \"{query}\"",
28+
"rate_limited": "Hit npm rate limit, try again in a moment",
2829
"title": "search",
2930
"title_search": "search: {search}",
3031
"title_packages": "search packages",

lunaria/files/en-GB.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"found_packages": "No packages found | Found 1 package | Found {count} packages",
2626
"updating": "(updating...)",
2727
"no_results": "No packages found for \"{query}\"",
28+
"rate_limited": "Hit npm rate limit, try again in a moment",
2829
"title": "search",
2930
"title_search": "search: {search}",
3031
"title_packages": "search packages",

lunaria/files/en-US.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"found_packages": "No packages found | Found 1 package | Found {count} packages",
2626
"updating": "(updating...)",
2727
"no_results": "No packages found for \"{query}\"",
28+
"rate_limited": "Hit npm rate limit, try again in a moment",
2829
"title": "search",
2930
"title_search": "search: {search}",
3031
"title_packages": "search packages",

0 commit comments

Comments
 (0)