authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-11 10:35:54-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-11 10:35:54-07:00
logf23d0084b6e023ffb1af9e332d8666affc99d542
tree29739400873ad46de31f497aad13ae9034ba7620
parent8ac6846a0b855c3aad70adbf745ff113d4eb3f1f
signature Signed by PGP key B5690EEEBB952194

perf(github): resolve credentials once per GraphQL request (#18289)


2 files changed, 65 insertions(+), 33 deletions(-)

packages/source-control-github/src/server/GitHubApi.test.ts+44-8
...@@ -1,6 +1,5 @@...@@ -1,6 +1,5 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import * as NodeCrypto from "@effect/platform-node/NodeCrypto";2import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";4import * as Layer from "effect/Layer";
6import * as Redacted from "effect/Redacted";5import * as Redacted from "effect/Redacted";
...@@ -24,16 +23,20 @@ function harness(respond: (request: HttpClientRequest.HttpClientRequest) => Resp...@@ -24,16 +23,20 @@ function harness(respond: (request: HttpClientRequest.HttpClientRequest) => Resp
24 const requests: Array<HttpClientRequest.HttpClientRequest> = [];23 const requests: Array<HttpClientRequest.HttpClientRequest> = [];
25 let tokens = ["first", "second"];24 let tokens = ["first", "second"];
26 let invalidations = 0;25 let invalidations = 0;
26 let credentialReads = 0;
27 const credentials = Layer.succeed(27 const credentials = Layer.succeed(
28 GitHubCredentials.GitHubCredentials,28 GitHubCredentials.GitHubCredentials,
29 GitHubCredentials.GitHubCredentials.of({29 GitHubCredentials.GitHubCredentials.of({
30 get: (host) =>30 get: (host) =>
31 Effect.sync(() => ({31 Effect.sync(() => {
32 host,32 credentialReads++;
33 token: Redacted.make(tokens[0]!),33 return {
34 source: "gh" as const,34 host,
35 fingerprint: `${host}:${tokens[0]}`,35 token: Redacted.make(tokens[0]!),
36 })),36 source: "gh" as const,
37 fingerprint: `${host}:${tokens[0]}`,
38 };
39 }),
37 invalidate: () =>40 invalidate: () =>
38 Effect.sync(() => {41 Effect.sync(() => {
39 invalidations++;42 invalidations++;
...@@ -53,7 +56,12 @@ function harness(respond: (request: HttpClientRequest.HttpClientRequest) => Resp...@@ -53,7 +56,12 @@ function harness(respond: (request: HttpClientRequest.HttpClientRequest) => Resp
53 Layer.provideMerge(GitHubQuota.layer),56 Layer.provideMerge(GitHubQuota.layer),
54 Layer.provideMerge(SourceControlRateLimit.layer),57 Layer.provideMerge(SourceControlRateLimit.layer),
55 );58 );
56 return { layer, requests, invalidations: () => invalidations };59 return {
60 layer,
61 requests,
62 invalidations: () => invalidations,
63 credentialReads: () => credentialReads,
64 };
57}65}
5866
59const json = (body: unknown, init?: ResponseInit) =>67const json = (body: unknown, init?: ResponseInit) =>
...@@ -119,6 +127,34 @@ describe("GitHubApi", () => {...@@ -119,6 +127,34 @@ describe("GitHubApi", () => {
119 }).pipe(Effect.provide(layer));127 }).pipe(Effect.provide(layer));
120 });128 });
121129
130 it.effect("resolves GraphQL credentials once per request and refreshes after refusal", () => {
131 const { layer, requests, invalidations, credentialReads } = harness((request) =>
132 request.headers.authorization === "Bearer first"
133 ? new Response(null, { status: 401 })
134 : json({ data: { viewer: { login: "julius" } } }),
135 );
136 return Effect.gen(function* () {
137 const api = yield* GitHubApi.GitHubApi;
138 const input = {
139 host: "github.com",
140 operation: "viewer",
141 query: "query { viewer { login } }",
142 };
143 const refused = yield* Effect.flip(api.graphql(input));
144 expect(refused._tag).toBe("GitHubApiAuthenticationError");
145 expect(credentialReads()).toBe(1);
146 expect(invalidations()).toBe(1);
147
148 const body = yield* api.graphql(input);
149 expect(body).toContain('"login":"julius"');
150 expect(credentialReads()).toBe(2);
151 expect(requests.map((request) => request.headers.authorization)).toEqual([
152 "Bearer first",
153 "Bearer second",
154 ]);
155 }).pipe(Effect.provide(layer));
156 });
157
122 it.effect(158 it.effect(
123 "traces the operation, path, query and quota, never the query string or variables",159 "traces the operation, path, query and quota, never the query string or variables",
124 () => {160 () => {
packages/source-control-github/src/server/GitHubApi.ts+21-25
...@@ -534,8 +534,6 @@ export const make = Effect.gen(function* () {...@@ -534,8 +534,6 @@ export const make = Effect.gen(function* () {
534 const graphql: GitHubApi["Service"]["graphql"] = Effect.fn("GitHubApi.graphql")(534 const graphql: GitHubApi["Service"]["graphql"] = Effect.fn("GitHubApi.graphql")(
535 function* (input) {535 function* (input) {
536 const host = normalizeHost(input.host);536 const host = normalizeHost(input.host);
537 const { fingerprint } = yield* credential(host);
538 const scope = (yield* SourceControlRateLimit.CredentialScope) || fingerprint;
539 const allowReserve = input.allowReserve ?? (yield* SourceControlRateLimit.Interactive);537 const allowReserve = input.allowReserve ?? (yield* SourceControlRateLimit.Interactive);
540 // The document, never its variables: user text (bodies, search terms) travels as variables.538 // The document, never its variables: user text (bodies, search terms) travels as variables.
541 yield* Effect.annotateCurrentSpan({539 yield* Effect.annotateCurrentSpan({
...@@ -546,32 +544,30 @@ export const make = Effect.gen(function* () {...@@ -546,32 +544,30 @@ export const make = Effect.gen(function* () {
546 ? `${input.query.slice(0, TRACED_QUERY_MAX_CHARS)}…`544 ? `${input.query.slice(0, TRACED_QUERY_MAX_CHARS)}…`
547 : input.query,545 : input.query,
548 });546 });
549 return yield* Effect.gen(function* () {547 const response = yield* send({
550 const response = yield* send({548 host,
549 operation: input.operation,
550 request: HttpClientRequest.post(gitHubApiUrls(host).graphql).pipe(
551 HttpClientRequest.acceptJson,
552 HttpClientRequest.bodyJsonUnsafe({
553 query: input.query,
554 variables: input.variables ?? {},
555 }),
556 ),
557 maxResponseBytes: input.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES,
558 allowReserve,
559 acceptNotModified: false,
560 graphql: true,
561 });
562 // A body cut at the byte cap hides any `errors` past the cut and cannot be decoded.
563 if (response.truncated) {
564 return yield* new GitHubApiResponseError({
551 host,565 host,
552 operation: input.operation,566 operation: input.operation,
553 request: HttpClientRequest.post(gitHubApiUrls(host).graphql).pipe(567 status: response.status,
554 HttpClientRequest.acceptJson,
555 HttpClientRequest.bodyJsonUnsafe({
556 query: input.query,
557 variables: input.variables ?? {},
558 }),
559 ),
560 maxResponseBytes: input.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES,
561 allowReserve,
562 acceptNotModified: false,
563 graphql: true,
564 });568 });
565 // A body cut at the byte cap hides any `errors` past the cut and cannot be decoded.569 }
566 if (response.truncated) {570 return response.body;
567 return yield* new GitHubApiResponseError({
568 host,
569 operation: input.operation,
570 status: response.status,
571 });
572 }
573 return response.body;
574 }).pipe(Effect.provideService(SourceControlRateLimit.CredentialScope, scope));
575 },571 },
576 );572 );
577573