Skip to content

Commit f8cdb7c

Browse files
committed
fix(api): uses dynamic rate limit instead of hardcoded 5k
Queries limit field from GraphQL rateLimit object and displays it dynamically in the footer. GitHub Enterprise Cloud orgs grant 10k pts/hr instead of the standard 5k, causing the old hardcoded display to show impossible values like 9000/5k/hr. Warning threshold is now proportional (< limit * 0.1) instead of hardcoded < 500. Adds safePositiveInt guard to reject 0/NaN/Infinity limit values with fallback to previous or 5000.
1 parent f019549 commit f8cdb7c

File tree

10 files changed

+78
-59
lines changed

10 files changed

+78
-59
lines changed

e2e/settings.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ async function setupAuth(page: Page) {
2525
json: {
2626
data: {
2727
search: { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] },
28-
rateLimit: { remaining: 5000, resetAt: new Date(Date.now() + 3600000).toISOString() },
28+
rateLimit: { limit: 5000, remaining: 5000, resetAt: new Date(Date.now() + 3600000).toISOString() },
2929
},
3030
},
3131
})

e2e/smoke.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ async function setupAuth(page: Page) {
3434
json: {
3535
data: {
3636
search: { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] },
37-
rateLimit: { remaining: 5000, resetAt: new Date(Date.now() + 3600000).toISOString() },
37+
rateLimit: { limit: 5000, remaining: 5000, resetAt: new Date(Date.now() + 3600000).toISOString() },
3838
},
3939
},
4040
})
@@ -112,7 +112,7 @@ test("OAuth callback flow completes and redirects", async ({ page }) => {
112112
json: {
113113
data: {
114114
search: { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] },
115-
rateLimit: { remaining: 5000, resetAt: new Date(Date.now() + 3600000).toISOString() },
115+
rateLimit: { limit: 5000, remaining: 5000, resetAt: new Date(Date.now() + 3600000).toISOString() },
116116
},
117117
},
118118
})

src/app/components/dashboard/DashboardPage.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
} from "../../services/poll";
2222
import { clearAuth, user, onAuthCleared, DASHBOARD_STORAGE_KEY } from "../../stores/auth";
2323
import { getClient, getGraphqlRateLimit } from "../../services/github";
24+
import { formatCount } from "../../lib/format";
2425

2526
// ── Shared dashboard store (module-level to survive navigation) ─────────────
2627

@@ -377,8 +378,8 @@ export default function DashboardPage() {
377378
<Show when={getGraphqlRateLimit()}>
378379
{(rl) => (
379380
<div class="tooltip tooltip-left" data-tip={`GraphQL API Rate Limits — resets at ${rl().resetAt.toLocaleTimeString()}`}>
380-
<span class={`tabular-nums ${rl().remaining < 500 ? "text-warning" : ""}`}>
381-
API RL: {rl().remaining.toLocaleString()}/5k/hr
381+
<span class={`tabular-nums ${rl().remaining < rl().limit * 0.1 ? "text-warning" : ""}`}>
382+
API RL: {rl().remaining.toLocaleString()}/{formatCount(rl().limit)}/hr
382383
</span>
383384
</div>
384385
)}

src/app/services/api.ts

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,7 @@ interface GraphQLIssueSearchResponse {
263263
pageInfo: { hasNextPage: boolean; endCursor: string | null };
264264
nodes: (GraphQLIssueNode | null)[];
265265
};
266-
rateLimit?: { remaining: number; resetAt: string };
266+
rateLimit?: { limit: number; remaining: number; resetAt: string };
267267
}
268268

269269
interface GraphQLPRNode {
@@ -310,7 +310,7 @@ interface GraphQLPRSearchResponse {
310310
pageInfo: { hasNextPage: boolean; endCursor: string | null };
311311
nodes: (GraphQLPRNode | null)[];
312312
};
313-
rateLimit?: { remaining: number; resetAt: string };
313+
rateLimit?: { limit: number; remaining: number; resetAt: string };
314314
}
315315

316316
interface ForkCandidate {
@@ -325,8 +325,8 @@ interface ForkRepoResult {
325325
}
326326

327327
interface ForkQueryResponse {
328-
rateLimit?: { remaining: number; resetAt: string };
329-
[key: string]: ForkRepoResult | { remaining: number; resetAt: string } | undefined | null;
328+
rateLimit?: { limit: number; remaining: number; resetAt: string };
329+
[key: string]: ForkRepoResult | { limit: number; remaining: number; resetAt: string } | undefined | null;
330330
}
331331

332332
// ── GraphQL search query constants ───────────────────────────────────────────
@@ -353,7 +353,7 @@ const ISSUES_SEARCH_QUERY = `
353353
}
354354
}
355355
}
356-
rateLimit { remaining resetAt }
356+
rateLimit { limit remaining resetAt }
357357
}
358358
`;
359359

@@ -406,7 +406,7 @@ const PR_SEARCH_QUERY = `
406406
}
407407
}
408408
}
409-
rateLimit { remaining resetAt }
409+
rateLimit { limit remaining resetAt }
410410
}
411411
`;
412412

@@ -479,7 +479,7 @@ const LIGHT_COMBINED_SEARCH_QUERY = `
479479
}
480480
}
481481
}
482-
rateLimit { remaining resetAt }
482+
rateLimit { limit remaining resetAt }
483483
}
484484
${LIGHT_PR_FRAGMENT}
485485
`;
@@ -496,7 +496,7 @@ const LIGHT_PR_SEARCH_QUERY = `
496496
}
497497
}
498498
}
499-
rateLimit { remaining resetAt }
499+
rateLimit { limit remaining resetAt }
500500
}
501501
${LIGHT_PR_FRAGMENT}
502502
`;
@@ -507,7 +507,7 @@ interface LightPRSearchResponse {
507507
pageInfo: { hasNextPage: boolean; endCursor: string | null };
508508
nodes: (GraphQLLightPRNode | null)[];
509509
};
510-
rateLimit?: { remaining: number; resetAt: string };
510+
rateLimit?: { limit: number; remaining: number; resetAt: string };
511511
}
512512

513513
/** Phase 2 backfill query: enriches PRs with heavy fields using node IDs. */
@@ -541,7 +541,7 @@ const HEAVY_PR_BACKFILL_QUERY = `
541541
}
542542
}
543543
}
544-
rateLimit { remaining resetAt }
544+
rateLimit { limit remaining resetAt }
545545
}
546546
`;
547547

@@ -563,7 +563,7 @@ const HOT_PR_STATUS_QUERY = `
563563
}
564564
}
565565
}
566-
rateLimit { remaining resetAt }
566+
rateLimit { limit remaining resetAt }
567567
}
568568
`;
569569

@@ -577,7 +577,7 @@ interface HotPRStatusNode {
577577

578578
interface HotPRStatusResponse {
579579
nodes: (HotPRStatusNode | null)[];
580-
rateLimit?: { remaining: number; resetAt: string };
580+
rateLimit?: { limit: number; remaining: number; resetAt: string };
581581
}
582582

583583
interface GraphQLLightPRNode {
@@ -639,12 +639,12 @@ interface LightCombinedSearchResponse {
639639
pageInfo: { hasNextPage: boolean; endCursor: string | null };
640640
nodes: (GraphQLLightPRNode | null)[];
641641
};
642-
rateLimit?: { remaining: number; resetAt: string };
642+
rateLimit?: { limit: number; remaining: number; resetAt: string };
643643
}
644644

645645
interface HeavyBackfillResponse {
646646
nodes: (GraphQLHeavyPRNode | null)[];
647-
rateLimit?: { remaining: number; resetAt: string };
647+
rateLimit?: { limit: number; remaining: number; resetAt: string };
648648
}
649649

650650
// Max node IDs per nodes() query (GitHub limit)
@@ -663,7 +663,7 @@ interface SearchPageResult<T> {
663663
* caller-provided `processNode` callback. Handles partial errors, cap enforcement,
664664
* and rate limit tracking. Returns the count of items added by processNode.
665665
*/
666-
async function paginateGraphQLSearch<TResponse extends { search: SearchPageResult<TNode>; rateLimit?: { remaining: number; resetAt: string } }, TNode>(
666+
async function paginateGraphQLSearch<TResponse extends { search: SearchPageResult<TNode>; rateLimit?: { limit: number; remaining: number; resetAt: string } }, TNode>(
667667
octokit: GitHubOctokit,
668668
query: string,
669669
queryString: string,
@@ -815,11 +815,11 @@ async function runForkPRFallback(
815815
);
816816
}
817817

818-
const forkQuery = `query(${varDefs.join(", ")}) {\n${fragments.join("\n")}\nrateLimit { remaining resetAt }\n}`;
818+
const forkQuery = `query(${varDefs.join(", ")}) {\n${fragments.join("\n")}\nrateLimit { limit remaining resetAt }\n}`;
819819

820820
try {
821821
const forkResponse = await octokit.graphql<ForkQueryResponse>(forkQuery, variables);
822-
if (forkResponse.rateLimit) updateGraphqlRateLimit(forkResponse.rateLimit as { remaining: number; resetAt: string });
822+
if (forkResponse.rateLimit) updateGraphqlRateLimit(forkResponse.rateLimit as { limit: number; remaining: number; resetAt: string });
823823

824824
for (let i = 0; i < forkChunk.length; i++) {
825825
const data = forkResponse[`fork${i}`] as ForkRepoResult | null | undefined;
@@ -1475,11 +1475,11 @@ async function graphqlSearchPRs(
14751475
);
14761476
}
14771477

1478-
const forkQuery = `query(${varDefs.join(", ")}) {\n${fragments.join("\n")}\nrateLimit { remaining resetAt }\n}`;
1478+
const forkQuery = `query(${varDefs.join(", ")}) {\n${fragments.join("\n")}\nrateLimit { limit remaining resetAt }\n}`;
14791479

14801480
try {
14811481
const forkResponse = await octokit.graphql<ForkQueryResponse>(forkQuery, variables);
1482-
if (forkResponse.rateLimit) updateGraphqlRateLimit(forkResponse.rateLimit as { remaining: number; resetAt: string });
1482+
if (forkResponse.rateLimit) updateGraphqlRateLimit(forkResponse.rateLimit as { limit: number; remaining: number; resetAt: string });
14831483

14841484
for (let i = 0; i < forkChunk.length; i++) {
14851485
const data = forkResponse[`fork${i}`] as ForkRepoResult | null | undefined;

src/app/services/github.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const GitHubOctokit = Octokit.plugin(throttling, retry, paginateRest);
1616
type GitHubOctokitInstance = InstanceType<typeof GitHubOctokit>;
1717

1818
interface RateLimitInfo {
19+
limit: number;
1920
remaining: number;
2021
resetAt: Date;
2122
}
@@ -33,8 +34,13 @@ export function getGraphqlRateLimit(): RateLimitInfo | null {
3334
return _graphqlRateLimit();
3435
}
3536

36-
export function updateGraphqlRateLimit(rateLimit: { remaining: number; resetAt: string }): void {
37+
function safePositiveInt(raw: number | undefined, fallback: number): number {
38+
return raw != null && Number.isFinite(raw) && raw > 0 ? raw : fallback;
39+
}
40+
41+
export function updateGraphqlRateLimit(rateLimit: { limit: number; remaining: number; resetAt: string }): void {
3742
_setGraphqlRateLimit({
43+
limit: safePositiveInt(rateLimit.limit, _graphqlRateLimit()?.limit ?? 5000),
3844
remaining: rateLimit.remaining,
3945
resetAt: new Date(rateLimit.resetAt), // ISO 8601 string → Date
4046
});
@@ -43,8 +49,11 @@ export function updateGraphqlRateLimit(rateLimit: { remaining: number; resetAt:
4349
export function updateRateLimitFromHeaders(headers: Record<string, string>): void {
4450
const remaining = headers["x-ratelimit-remaining"];
4551
const reset = headers["x-ratelimit-reset"];
52+
const limit = headers["x-ratelimit-limit"];
4653
if (remaining !== undefined && reset !== undefined) {
54+
const parsedLimit = limit !== undefined ? parseInt(limit, 10) : NaN;
4755
_setCoreRateLimit({
56+
limit: safePositiveInt(parsedLimit, 5000),
4857
remaining: parseInt(remaining, 10),
4958
resetAt: new Date(parseInt(reset, 10) * 1000),
5059
});

tests/integration/data-pipeline.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ function makeGraphqlSearchResponse(nodes = [graphqlIssueNode]) {
6464
pageInfo: { hasNextPage: false, endCursor: null },
6565
nodes,
6666
},
67-
rateLimit: { remaining: 4999, resetAt: new Date(Date.now() + 3600000).toISOString() },
67+
rateLimit: { limit: 5000, remaining: 4999, resetAt: new Date(Date.now() + 3600000).toISOString() },
6868
};
6969
}
7070

tests/services/api-optimization.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ function makeHeavyPRNode(databaseId: number, _nodeId?: string) {
108108
};
109109
}
110110

111-
const rateLimit = { remaining: 4999, resetAt: new Date(Date.now() + 3600000).toISOString() };
111+
const rateLimit = { limit: 5000, remaining: 4999, resetAt: new Date(Date.now() + 3600000).toISOString() };
112112

113113
function makeSearchResponse<T>(nodes: T[], hasNextPage = false) {
114114
return {

tests/services/api.test.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,7 @@ function makeGraphqlIssueResponse(nodes = [graphqlIssueNode], hasNextPage = fals
193193
pageInfo: { hasNextPage, endCursor: hasNextPage ? "cursor1" : null },
194194
nodes,
195195
},
196-
rateLimit: { remaining: 4999, resetAt: new Date(Date.now() + 3600000).toISOString() },
196+
rateLimit: { limit: 5000, remaining: 4999, resetAt: new Date(Date.now() + 3600000).toISOString() },
197197
};
198198
}
199199

@@ -371,7 +371,7 @@ describe("fetchIssues", () => {
371371
pageInfo: { hasNextPage: false, endCursor: null },
372372
nodes: [graphqlIssueNode, null, { ...graphqlIssueNode, databaseId: 999 }],
373373
},
374-
rateLimit: { remaining: 4999, resetAt: new Date(Date.now() + 3600000).toISOString() },
374+
rateLimit: { limit: 5000, remaining: 4999, resetAt: new Date(Date.now() + 3600000).toISOString() },
375375
}));
376376

377377
const result = await fetchIssues(
@@ -427,7 +427,7 @@ describe("fetchIssues", () => {
427427
pageInfo: { hasNextPage: true, endCursor: "cursor-partial" },
428428
nodes: [{ ...graphqlIssueNode, databaseId: 42 }, null],
429429
},
430-
rateLimit: { remaining: 4990, resetAt: new Date(Date.now() + 3600000).toISOString() },
430+
rateLimit: { limit: 5000, remaining: 4990, resetAt: new Date(Date.now() + 3600000).toISOString() },
431431
},
432432
});
433433
const octokit = makeIssueOctokit(async () => {
@@ -563,7 +563,7 @@ function makeGraphqlPRResponse(nodes = [graphqlPRNode], hasNextPage = false, iss
563563
pageInfo: { hasNextPage, endCursor: hasNextPage ? "cursor1" : null },
564564
nodes,
565565
},
566-
rateLimit: { remaining: 4999, resetAt: new Date(Date.now() + 3600000).toISOString() },
566+
rateLimit: { limit: 5000, remaining: 4999, resetAt: new Date(Date.now() + 3600000).toISOString() },
567567
};
568568
}
569569

@@ -714,7 +714,7 @@ describe("fetchPullRequests", () => {
714714
// Fork fallback query
715715
return {
716716
fork0: { object: { statusCheckRollup: { state: "SUCCESS" } } },
717-
rateLimit: { remaining: 4999, resetAt: new Date(Date.now() + 3600000).toISOString() },
717+
rateLimit: { limit: 5000, remaining: 4999, resetAt: new Date(Date.now() + 3600000).toISOString() },
718718
};
719719
});
720720

@@ -788,7 +788,7 @@ describe("fetchPullRequests", () => {
788788
pageInfo: { hasNextPage: true, endCursor: null }, // degenerate response
789789
nodes: [{ ...graphqlPRNode, databaseId: callCount }],
790790
},
791-
rateLimit: { remaining: 4999, resetAt: new Date(Date.now() + 3600000).toISOString() },
791+
rateLimit: { limit: 5000, remaining: 4999, resetAt: new Date(Date.now() + 3600000).toISOString() },
792792
};
793793
});
794794

@@ -874,7 +874,7 @@ describe("fetchPullRequests", () => {
874874
data: {
875875
fork0: { object: { statusCheckRollup: { state: "SUCCESS" } } },
876876
// fork1 is missing — that fork repo was deleted/inaccessible
877-
rateLimit: { remaining: 4990, resetAt: new Date(Date.now() + 3600000).toISOString() },
877+
rateLimit: { limit: 5000, remaining: 4990, resetAt: new Date(Date.now() + 3600000).toISOString() },
878878
},
879879
});
880880
});
@@ -951,7 +951,7 @@ describe("fetchPullRequests", () => {
951951
pageInfo: { hasNextPage: true, endCursor: "cursor-partial" },
952952
nodes: [{ ...graphqlPRNode, databaseId: 77 }],
953953
},
954-
rateLimit: { remaining: 4990, resetAt: new Date(Date.now() + 3600000).toISOString() },
954+
rateLimit: { limit: 5000, remaining: 4990, resetAt: new Date(Date.now() + 3600000).toISOString() },
955955
},
956956
});
957957
const octokit = makePROctokit(async (_query, variables) => {
@@ -1038,7 +1038,7 @@ describe("fetchPullRequests", () => {
10381038
}
10391039
// Fork fallback queries
10401040
const response: Record<string, unknown> = {
1041-
rateLimit: { remaining: 4999, resetAt: new Date(Date.now() + 3600000).toISOString() },
1041+
rateLimit: { limit: 5000, remaining: 4999, resetAt: new Date(Date.now() + 3600000).toISOString() },
10421042
};
10431043
const indices = Object.keys(variables as Record<string, unknown>)
10441044
.filter((k) => k.startsWith("owner"))

tests/services/github.test.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -305,18 +305,27 @@ describe("getGraphqlRateLimit", () => {
305305

306306
it("converts ISO 8601 resetAt string to Date", () => {
307307
const iso = "2024-06-01T12:00:00Z";
308-
updateGraphqlRateLimit({ remaining: 4500, resetAt: iso });
308+
updateGraphqlRateLimit({ limit: 5000, remaining: 4500, resetAt: iso });
309309
const rl = getGraphqlRateLimit();
310310
expect(rl).not.toBeNull();
311+
expect(rl!.limit).toBe(5000);
311312
expect(rl!.remaining).toBe(4500);
312313
expect(rl!.resetAt).toBeInstanceOf(Date);
313314
expect(rl!.resetAt.getTime()).toBe(new Date(iso).getTime());
314315
});
315316

317+
it("stores limit from Enterprise Cloud (10000)", () => {
318+
updateGraphqlRateLimit({ limit: 10000, remaining: 9500, resetAt: "2024-06-01T12:00:00Z" });
319+
const rl = getGraphqlRateLimit();
320+
expect(rl!.limit).toBe(10000);
321+
expect(rl!.remaining).toBe(9500);
322+
});
323+
316324
it("overwrites previous value on subsequent updates", () => {
317-
updateGraphqlRateLimit({ remaining: 5000, resetAt: "2024-06-01T12:00:00Z" });
318-
updateGraphqlRateLimit({ remaining: 3000, resetAt: "2024-06-01T13:00:00Z" });
325+
updateGraphqlRateLimit({ limit: 5000, remaining: 5000, resetAt: "2024-06-01T12:00:00Z" });
326+
updateGraphqlRateLimit({ limit: 5000, remaining: 3000, resetAt: "2024-06-01T13:00:00Z" });
319327
const rl = getGraphqlRateLimit();
328+
expect(rl!.limit).toBe(5000);
320329
expect(rl!.remaining).toBe(3000);
321330
});
322331
});

0 commit comments

Comments
 (0)