Skip to content

Commit 5757ade

Browse files
committed
perf: serve stale sponsors when github is unavailable
1 parent 1bacd4d commit 5757ade

File tree

2 files changed

+162
-44
lines changed

2 files changed

+162
-44
lines changed

src/server/github.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,116 @@
11
import { graphql } from '@octokit/graphql'
22
import { env } from '~/utils/env'
33

4+
export type GitHubApiErrorKind =
5+
| 'rate_limited'
6+
| 'forbidden'
7+
| 'unauthorized'
8+
| 'upstream'
9+
| 'unknown'
10+
11+
export class GitHubApiError extends Error {
12+
kind: GitHubApiErrorKind
13+
status?: number
14+
resetAt?: Date
15+
16+
constructor(opts: {
17+
message: string
18+
kind: GitHubApiErrorKind
19+
status?: number
20+
resetAt?: Date
21+
}) {
22+
super(opts.message)
23+
this.name = 'GitHubApiError'
24+
this.kind = opts.kind
25+
this.status = opts.status
26+
this.resetAt = opts.resetAt
27+
}
28+
}
29+
30+
function getHeader(
31+
headers: Record<string, string> | undefined,
32+
key: string,
33+
): string | undefined {
34+
if (!headers) return undefined
35+
36+
const loweredKey = key.toLowerCase()
37+
const entry = Object.entries(headers).find(
38+
([headerKey]) => headerKey.toLowerCase() === loweredKey,
39+
)
40+
41+
return entry?.[1]
42+
}
43+
44+
function parseRateLimitReset(headers: Record<string, string> | undefined) {
45+
const resetHeader = getHeader(headers, 'x-ratelimit-reset')
46+
47+
if (!resetHeader) return undefined
48+
49+
const epochSeconds = Number.parseInt(resetHeader, 10)
50+
if (Number.isNaN(epochSeconds)) return undefined
51+
52+
return new Date(epochSeconds * 1000)
53+
}
54+
55+
export function normalizeGitHubApiError(error: unknown, context: string) {
56+
const status =
57+
typeof error === 'object' && error && 'status' in error
58+
? Number(error.status)
59+
: undefined
60+
const responseHeaders =
61+
typeof error === 'object' && error && 'response' in error
62+
? (error.response as { headers?: Record<string, string> }).headers
63+
: undefined
64+
const message =
65+
error instanceof Error ? error.message : `${context} failed`
66+
67+
if (status === 401) {
68+
return new GitHubApiError({
69+
kind: 'unauthorized',
70+
status,
71+
message: `${context} was unauthorized`,
72+
})
73+
}
74+
75+
if (status === 403 && getHeader(responseHeaders, 'x-ratelimit-remaining') === '0') {
76+
const resetAt = parseRateLimitReset(responseHeaders)
77+
return new GitHubApiError({
78+
kind: 'rate_limited',
79+
status,
80+
resetAt,
81+
message: resetAt
82+
? `${context} hit the GitHub rate limit until ${resetAt.toISOString()}`
83+
: `${context} hit the GitHub rate limit`,
84+
})
85+
}
86+
87+
if (status === 403) {
88+
return new GitHubApiError({
89+
kind: 'forbidden',
90+
status,
91+
message: `${context} was forbidden`,
92+
})
93+
}
94+
95+
if (typeof status === 'number' && status >= 500) {
96+
return new GitHubApiError({
97+
kind: 'upstream',
98+
status,
99+
message: `${context} failed with GitHub ${status}`,
100+
})
101+
}
102+
103+
return new GitHubApiError({
104+
kind: 'unknown',
105+
status,
106+
message,
107+
})
108+
}
109+
110+
export function isRecoverableGitHubApiError(error: unknown) {
111+
return error instanceof GitHubApiError
112+
}
113+
4114
export const graphqlWithAuth = graphql.defaults({
5115
headers: {
6116
authorization: `token ${env.GITHUB_AUTH_TOKEN}`,

src/server/sponsors.ts

Lines changed: 52 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1-
import { fetchCached } from '~/utils/cache.server'
2-
import { graphqlWithAuth } from '~/server/github'
1+
import { fetchCachedWithStaleFallback } from '~/utils/cache.server'
2+
import {
3+
graphqlWithAuth,
4+
isRecoverableGitHubApiError,
5+
normalizeGitHubApiError,
6+
} from '~/server/github'
37
import { createServerFn } from '@tanstack/react-start'
48
import { setResponseHeaders } from '@tanstack/react-start/server'
59
import sponsorMetaData from '~/utils/gh-sponsor-meta.json'
@@ -24,15 +28,35 @@ export type Sponsor = {
2428
createdAt: string
2529
}
2630

31+
const SPONSOR_CACHE_TTL_MS = process.env.NODE_ENV === 'development' ? 1 : 5 * 60 * 1000
32+
2733
export const getSponsorsForSponsorPack = createServerFn({
2834
method: 'GET',
2935
}).handler(async () => {
30-
const sponsors = await fetchCached({
31-
key: 'sponsors',
32-
// ttl: process.env.NODE_ENV === 'development' ? 1 : 60 * 60 * 1000,
33-
ttl: 60 * 1000,
34-
fn: getSponsors,
35-
})
36+
let sponsors: Sponsor[]
37+
38+
try {
39+
sponsors = await fetchCachedWithStaleFallback({
40+
key: 'sponsors',
41+
ttl: SPONSOR_CACHE_TTL_MS,
42+
fn: getSponsors,
43+
shouldFallbackToStale: isRecoverableGitHubApiError,
44+
onStaleFallback: (error) => {
45+
console.warn('[getSponsorsForSponsorPack] Serving stale sponsors after GitHub failure:', {
46+
message: error instanceof Error ? error.message : String(error),
47+
})
48+
},
49+
})
50+
} catch (error) {
51+
if (isRecoverableGitHubApiError(error)) {
52+
console.warn('[getSponsorsForSponsorPack] Falling back to metadata-only sponsors:', {
53+
message: error.message,
54+
})
55+
sponsors = await getSponsorsFromMetadataOnly()
56+
} else {
57+
throw error
58+
}
59+
}
3660

3761
// In recent @tanstack/react-start versions, getEvent is no longer exported.
3862
// Headers can be set unconditionally here; framework will merge appropriately.
@@ -58,14 +82,12 @@ export const getSponsorsForSponsorPack = createServerFn({
5882
}))
5983
})
6084

61-
export async function getSponsors() {
62-
const [sponsors, sponsorsMeta] = await Promise.all([
63-
getGithubSponsors(),
64-
getSponsorsMeta(),
65-
])
66-
85+
function mergeSponsorsWithMetadata(
86+
sponsors: Sponsor[],
87+
sponsorsMeta: SponsorMeta[],
88+
) {
6789
sponsorsMeta.forEach((sponsorMeta: SponsorMeta) => {
68-
const matchingSponsor = sponsors.find((d) => d.login == sponsorMeta.login)
90+
const matchingSponsor = sponsors.find((d) => d.login === sponsorMeta.login)
6991

7092
if (matchingSponsor) {
7193
Object.assign(matchingSponsor, {
@@ -95,6 +117,19 @@ export async function getSponsors() {
95117
return sponsors
96118
}
97119

120+
export async function getSponsors() {
121+
const [sponsors, sponsorsMeta] = await Promise.all([
122+
getGithubSponsors(),
123+
getSponsorsMeta(),
124+
])
125+
126+
return mergeSponsorsWithMetadata(sponsors, sponsorsMeta)
127+
}
128+
129+
async function getSponsorsFromMetadataOnly() {
130+
return mergeSponsorsWithMetadata([], await getSponsorsMeta())
131+
}
132+
98133
async function getGithubSponsors() {
99134
let sponsors: Sponsor[] = []
100135
try {
@@ -205,35 +240,8 @@ async function getGithubSponsors() {
205240
await fetchPage()
206241

207242
return sponsors
208-
} catch (err) {
209-
const error = err as { status?: number }
210-
if (error.status === 401) {
211-
console.error('Missing github credentials, returning mock data.')
212-
return [
213-
'tannerlinsley',
214-
'tkdodo',
215-
'crutchcorn',
216-
'kevinvandy',
217-
'jherr',
218-
'seancassiere',
219-
'schiller-manuel',
220-
].flatMap((d) =>
221-
new Array(20).fill(d).map((_, i2) => ({
222-
login: d,
223-
name: d,
224-
amount: (20 - i2) / 20 + Math.random(),
225-
createdAt: new Date().toISOString(),
226-
private: false,
227-
linkUrl: `https://github.com/${d}`,
228-
imageUrl: `https://github.com/${d}.png`,
229-
})),
230-
)
231-
}
232-
if (error.status === 403) {
233-
console.error('GitHub rate limit exceeded, returning empty sponsors.')
234-
return []
235-
}
236-
throw err
243+
} catch (error) {
244+
throw normalizeGitHubApiError(error, 'Fetching GitHub sponsors')
237245
}
238246
}
239247

0 commit comments

Comments
 (0)