fix(api): return fallback SVG and 429 on GitHub GraphQL rate-limit ex…#5802
fix(api): return fallback SVG and 429 on GitHub GraphQL rate-limit ex…#5802Payal-mak wants to merge 2 commits into
Conversation
|
@Payal-mak is attempting to deploy a commit to the jhasourav07's projects Team on Vercel. A member of the Team first needs to authorize it. |
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Introduce a typed RateLimitError with optional backoff metadata so API routes can reliably detect GitHub GraphQL rate-limiting and respond with 429 + Retry-After.
Changes:
- Add/extend
RateLimitErrorto carryretryAfterMsand throw it on GitHub GraphQLRATE_LIMITED. - Attach
Retry-Afterheader in the streak API when the error is aRateLimitError. - Expand unit/integration tests around typed rate-limit handling and headers.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| lib/github.ts | Throws a typed RateLimitError with computed backoff from rate-limit headers. |
| lib/github.test.ts | Adds/updates tests asserting RateLimitError behavior and backoff propagation. |
| app/api/wrapped/route.test.ts | Updates wrapped API tests, but currently contains unresolved merge markers. |
| app/api/streak/tests/refresh.test.ts | Adjusts mocks for streak refresh tests, but currently contains unresolved merge markers. |
| app/api/streak/route.ts | Sets Retry-After header on 429 responses when a typed rate-limit error is thrown. |
| app/api/streak/route.test.ts | Adds tests covering 429 + Retry-After behavior; updates mocking to keep RateLimitError. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| <<<<<<< HEAD | ||
| expect(body).toContain('API RATE LIMIT'); | ||
| ======= | ||
| expect(body).toContain('rate limit'); //change to lowercase | ||
| >>>>>>> 8460ceee (fix(api): return fallback SVG and 429 on GitHub GraphQL rate-limit exhaustion) |
| <<<<<<< HEAD | ||
| vi.mock('../../../../lib/github', () => ({ | ||
| fetchGitHubContributions: vi.fn(), | ||
| getOrgDashboardData: vi.fn(), | ||
| getCircuitTelemetry: vi.fn().mockReturnValue({ isOpen: false, resetInMs: 0 }), | ||
| })); | ||
| ======= | ||
| vi.mock('../../../../lib/github', async (importOriginal) => { | ||
| const actual = await importOriginal<typeof import('../../../../lib/github')>(); | ||
| return { | ||
| ...actual, | ||
| fetchGitHubContributions: vi.fn(), | ||
| getOrgDashboardData: vi.fn(), | ||
| getCircuitTelemetry: vi.fn(() => ({ isOpen: false, resetInMs: 0 })), | ||
| }; | ||
| }); | ||
| >>>>>>> 8460ceee (fix(api): return fallback SVG and 429 on GitHub GraphQL rate-limit exhaustion) |
| const resetAt = res.headers.get('x-ratelimit-reset') ?? undefined; | ||
| const retryAfterMs = resetAt | ||
| ? Math.max(0, parseInt(resetAt, 10) * 1000 - Date.now()) | ||
| : 60_000; |
| const promise = fetchGitHubContributions('octocat'); | ||
| const assertion = expect(promise).rejects.toThrow('API Rate Limit Exceeded'); | ||
| await vi.advanceTimersByTimeAsync(3500); | ||
| await assertion; | ||
| await vi.advanceTimersByTimeAsync(10000); // promise rejects HERE, with no handler yet | ||
| await expect(promise).rejects.toBeInstanceOf(RateLimitError); |
| // --------------------------------------------------------------------------- | ||
| // fetchGitHubContributions — GraphQL RATE_LIMITED → typed RateLimitError | ||
| // --------------------------------------------------------------------------- | ||
| describe('fetchGitHubContributions — GraphQL RATE_LIMITED typed error', () => { |
| vi.mock('../../../lib/github', () => { | ||
| class RateLimitError extends Error { | ||
| public retryAfterMs?: number; | ||
| constructor(message: string, retryAfterMs?: number) { | ||
| super(message); | ||
| this.name = 'RateLimitError'; | ||
| this.retryAfterMs = retryAfterMs; | ||
| } | ||
| } | ||
| return { | ||
| fetchGitHubContributions: vi.fn(), | ||
| getOrgDashboardData: vi.fn(), | ||
| getCircuitTelemetry: vi.fn().mockReturnValue({ isOpen: false, resetInMs: 0 }), | ||
| RateLimitError, | ||
| }; | ||
| }); |
|
🚨 Hey @Payal-mak, the CI Pipeline is failing on this PR and it has been marked as Please fix the issues before this can be reviewed. Here's how: 1. Run checks locally before pushing: npm run format:check # Check Prettier formatting
npm run lint # Run ESLint
npm run typecheck # TypeScript type check
npm run test # Run unit tests (Vitest)
npm run build # Verify production build passes2. Auto-fix common issues: npm run format # Auto-fix formatting with Prettier
npm run lint -- --fix # Auto-fix lint errors where possible3. Check the full failure log here: Once you push a fix and the CI passes, the |
ce0cc4d to
36b4a06
Compare
Aamod-Dev
left a comment
There was a problem hiding this comment.
Good rate-limit handling
Aamod-Dev
left a comment
There was a problem hiding this comment.
Difficulty: intermediate – Returns fallback SVG and 429 status on GitHub GraphQL rate-limit exceeded.
Quality: clean – Graceful degradation.
Type: bug – Rate limit handling.
Good fallback!
Aamod-Dev
left a comment
There was a problem hiding this comment.
Review
This PR cannot be approved in its current state due to blocking issues (status:blocked label, merge conflicts, needs-rebase label, and/or failing CI checks). Please resolve the blocking issues and re-request review.
Once unblocked, I'm happy to re-review! 💚
Description
Fixes #5404
The
/api/streakendpoint previously had no handling for GitHub GraphQL rate-limit exhaustion — a rate-limited response would surface as an unhandled500or blank body, breaking the badge image in READMEs with no recovery path.This PR brings
/api/streakin line with the existing rate-limit/circuit-breaker pattern already used by/api/wrapped:RateLimitError(lib/github.ts):fetchGitHubContributionsnow detectserrors[].type === "RATE_LIMITED"in the GraphQL response body and throws a typedRateLimitErrorcarrying a computedretryAfterMs(derived from thex-ratelimit-resetheader, falling back to 60s)./api/streaknow returns the sharedgenerateRateLimitSVGbadge (matching the existing premium "rate limit / circuit breaker" design from/api/wrapped) instead of an empty body or stack trace.429withCache-Control: no-store.Retry-Afterheader: whenRateLimitError.retryAfterMsis available,buildErrorResponseattaches aRetry-Afterheader (in seconds) so clients and CDNs back off correctly./api/streaknow callsgetCircuitTelemetry()to include circuit-open status in the rate-limit response, consistent with/api/wrapped.quotaMonitorandrefreshRateLimiterservices so/api/streakreturns429proactively when GitHub API quota is low or per-IP refresh limits are exceeded — before even attempting a GraphQL call.Files Changed
lib/github.ts— addedRateLimitErrorclass (withretryAfterMs), typed throw on body-levelRATE_LIMITEDapp/api/streak/route.ts—buildErrorResponsenow handlesRateLimitError(429,Retry-After, fallback SVG, circuit telemetry headers)app/api/streak/tests/refresh.test.ts— updatedlib/githubmock to useimportOriginal(preserves realRateLimitErrorclass forinstanceofchecks) and addedgetCircuitTelemetrymockapp/api/wrapped/route.test.ts— updated one assertion to match the shared SVG's actual rendered text ('rate limit')lib/github.test.ts— added test coverage forRateLimitErrorclass contract and the typed-error path on body-levelRATE_LIMITEDresponsesPillar
Visual Preview
then,
Checklist before requesting a review:
CONTRIBUTING.mdfile.localhost:3000/api/streak?user=YOUR_USERNAME).npm run formatandnpm run lintlocally and resolved all errors (CI will fail otherwise).feat(themes): ...,fix(calculate): ...).README.mdif I added a new theme or URL parameter. (N/A — no new theme or URL parameter added)