From ac4fb1f1d0962189cd57a9ba88dabefe3fb37e37 Mon Sep 17 00:00:00 2001 From: timofeyevvv Date: Mon, 17 Aug 2026 21:15:34 +0300 Subject: [PATCH] fix: avoid 500s on component pages from cached empty readme (#729) - Fetch readme/changelog/CODEOWNERS through the authenticated Octokit client instead of unauthenticated raw.githubusercontent.com requests, which share a per-IP rate limit and intermittently returned errors in production (with one retry; 404 still falls back to the default locale) - Throw on empty component readme instead of returning '', so a transient fetch failure is retried on the next request instead of being cached as valid empty content for the whole TTL - Wait for readme cache revalidation instead of immediately returning a possibly empty cache entry - Don't let a rejected in-flight query escape CacheQuery.getData past the onError handler - Respond 404 instead of 500 for components without a readme url (e.g. isComingSoon ones like /components/navigation/action-bar) Co-Authored-By: Claude Fable 5 --- src/api/cache-query.ts | 7 +- src/api/server.ts | 139 ++++++++++++------ .../components/[libId]/[componentId].tsx | 8 +- 3 files changed, 103 insertions(+), 51 deletions(-) diff --git a/src/api/cache-query.ts b/src/api/cache-query.ts index 3aac71720643..1340ef3b5985 100644 --- a/src/api/cache-query.ts +++ b/src/api/cache-query.ts @@ -91,7 +91,12 @@ export class CacheQuery { return this._data; } - await this._currentQueryPromise; + try { + await this._currentQueryPromise; + } catch { + // Rejection is handled in revalidate(), which shares this promise; + // awaiting here must not rethrow past onError. + } return this._data; } diff --git a/src/api/server.ts b/src/api/server.ts index 2a47288ec826..c4b14f472f46 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -136,13 +136,18 @@ export class ServerApi { async fetchRepositoryCodeOwners(repoOwner: string, repo: string): Promise { const url = `https://raw.githubusercontent.com/${repoOwner}/${repo}/main/CODEOWNERS`; - const res = await fetch(url); - if (!res.ok) { + let codeOwnersText: string | null = null; + try { + codeOwnersText = await this.fetchGithubRawFile(url); + } catch (err) { + console.error(err); + } + + if (!codeOwnersText) { return []; } - const codeOwnersText = await res.text(); const lines = codeOwnersText.split('\n'); const codeOwners: CodeOwners[] = []; @@ -301,15 +306,8 @@ export class ServerApi { async fetchChangelogInfo(changelogUrl: string): Promise { if (!changelogUrl) return ''; - const headers: Record = {'User-Agent': 'request'}; - try { - const response = await fetch(changelogUrl, { - headers, - }); - if (response.ok) { - return await response.text(); - } + return (await this.fetchGithubRawFile(changelogUrl)) ?? ''; } catch (err) { console.error(err); } @@ -328,16 +326,9 @@ export class ServerApi { pt: string; ja: string; }> { - const headers: Record = {'User-Agent': 'request'}; - const fetchReadmeContent = async (url: string) => { try { - const response = await fetch(url, { - headers, - }); - if (response.ok) { - return await response.text(); - } + return (await this.fetchGithubRawFile(url)) ?? ''; } catch (err) { console.error(err); } @@ -507,36 +498,33 @@ export class ServerApi { componentId: string; locale: string; }): Promise { - let readmeContent = ''; + let readmeContent: string | null = null; - const headers: Record = {'User-Agent': 'request'}; - - try { - if (locale !== 'en' && locale !== 'ru') { - try { - readmeContent = await import( - `../content/local-docs/components/${libId}/${componentId}/README-${locale}.md` - ).then((module) => module.default); - } catch (err) { - console.warn( - `Can't find local docs for "${componentId}", library "${libId}", lang "${locale}"`, - ); - } - } else { - const res = await fetch(readmeUrl[locale], {headers}); - if (res.status >= 200 && res.status < 300) { - readmeContent = await res.text(); - } + if (locale !== 'en' && locale !== 'ru') { + try { + readmeContent = await import( + `../content/local-docs/components/${libId}/${componentId}/README-${locale}.md` + ).then((module) => module.default); + } catch (err) { + console.warn( + `Can't find local docs for "${componentId}", library "${libId}", lang "${locale}"`, + ); } + } else { + readmeContent = await this.fetchGithubRawFile(readmeUrl[locale]); + } - if (!readmeContent && locale !== i18n.defaultLocale) { - const fallbackRes = await fetch(readmeUrl[i18n.defaultLocale], {headers}); - if (fallbackRes.status >= 200 && fallbackRes.status < 300) { - readmeContent = await fallbackRes.text(); - } - } - } catch (err) { - console.warn('Error fetching component README:', err); + if (!readmeContent && locale !== i18n.defaultLocale) { + readmeContent = await this.fetchGithubRawFile(readmeUrl[i18n.defaultLocale]); + } + + // Throwing (instead of returning '') keeps CacheQuery in the error state, + // so a transient fetch failure is retried on the next request instead of + // being cached as valid empty content for the whole TTL. + if (!readmeContent) { + throw new Error( + `Got empty README for component "${componentId}" (lib "${libId}", locale "${locale}")`, + ); } return readmeContent; @@ -564,7 +552,13 @@ export class ServerApi { }); } - const content = await this.componentsReadmeCache?.[cacheKey]?.getData?.(); + // Waiting for revalidation (instead of immediately returning a possibly + // empty cache) means a request hitting a stale or errored cache entry gets + // fresh content rather than a 500. Stale-but-present data is still served + // if the refetch fails. + const content = await this.componentsReadmeCache?.[cacheKey]?.getData?.({ + immediateResponse: false, + }); if (!content) { throw new Error(`Can't find README for ${cacheKey}`); @@ -762,4 +756,55 @@ export class ServerApi { // TODO: Implement when connecting to real API return []; } + + /** + * Fetches a file that would normally be requested from raw.githubusercontent.com + * through the authenticated GitHub API client. Unauthenticated raw.githubusercontent.com + * requests share a per-IP rate limit, which intermittently produced empty responses + * in production (see issue #729), while API requests count against the much higher + * app/token limit. Returns null when the file doesn't exist (404) so callers can + * fall back to another locale; throws on other errors after one retry so failures + * are not cached as valid content. + * @param url - Original raw.githubusercontent.com file URL + * @returns File content, or null if the file doesn't exist + */ + private async fetchGithubRawFile(url: string): Promise { + const match = url.match( + /^https:\/\/raw\.githubusercontent\.com\/([^/]+)\/([^/]+)\/([^/]+)\/(.+)$/, + ); + + if (!match) { + const response = await fetch(url, {headers: {'User-Agent': 'request'}}); + return response.ok ? await response.text() : null; + } + + const [, owner, repo, ref, filePath] = match; + + let lastError: unknown; + for (let attempt = 0; attempt < 2; attempt++) { + if (attempt > 0) { + await new Promise((resolve) => setTimeout(resolve, 500)); + } + + try { + const response = await this.octokit.rest.repos.getContent({ + owner, + repo, + path: filePath, + ref, + mediaType: {format: 'raw'}, + }); + + return response.data as unknown as string; + } catch (error) { + if ((error as {status?: number}).status === 404) { + return null; + } + + lastError = error; + } + } + + throw lastError; + } } diff --git a/src/pages/components/[libId]/[componentId].tsx b/src/pages/components/[libId]/[componentId].tsx index 83ca73960bd6..4a5ae9c59c45 100644 --- a/src/pages/components/[libId]/[componentId].tsx +++ b/src/pages/components/[libId]/[componentId].tsx @@ -27,10 +27,12 @@ export const getServerSideProps: GetServerSideProps = async (ctx) => { }; } + // Components without a readme (e.g. isComingSoon) have no page — respond + // with 404 instead of an unhandled error that turns into a 500. if (!component.content?.readmeUrl) { - throw new Error( - `Component "${ctx.params?.componentId}" in library "${ctx.params?.libId}" doesn't have url for readme file`, - ); + return { + notFound: true, + }; } const locale = ctx.locale ?? i18nextConfig.i18n.defaultLocale;