@@ -45,6 +45,23 @@ export function setGitHubResponseCache(cache: GitHubResponseCache | null): void
4545
4646export type GitHubCacheClass = "branch_protection" | "metadata" ;
4747type EnvLookup = Record < string , string | undefined > ;
48+ export type GitHubTimeoutFetchInit = RequestInit & {
49+ /** Opt in to using this response's REST bucket headers for self-host queue admission control. */
50+ githubRateLimitAdmission ?: boolean ;
51+ /** Stable actor key for admission control. Installation-token reads should use the installation id. */
52+ githubRateLimitAdmissionKey ?: string ;
53+ } ;
54+ export type GitHubRateLimitAdmissionKey = string ;
55+ export type LocalGitHubRestRateLimitObservation = {
56+ remaining : number ;
57+ resetAt : string ;
58+ observedAtMs : number ;
59+ } ;
60+ const latestRestRateLimitObservations = new Map < GitHubRateLimitAdmissionKey , LocalGitHubRestRateLimitObservation > ( ) ;
61+
62+ export function githubRateLimitAdmissionKeyForInstallation ( installationId : number ) : GitHubRateLimitAdmissionKey {
63+ return `installation:${ Math . trunc ( installationId ) } ` ;
64+ }
4865
4966/** Only cache explicitly stable GitHub REST reads. PR/issue/comment/label/event/check/status reads are mutable
5067 * review inputs and must always reflect the current GitHub state. Exported for tests. */
@@ -86,6 +103,13 @@ export function githubResponseCacheTtlSeconds(cls: GitHubCacheClass, env: EnvLoo
86103 return positiveEnvSeconds ( env , "GITHUB_METADATA_CACHE_TTL_SECONDS" , DEFAULT_METADATA_TTL_SECONDS ) ;
87104}
88105
106+ function isCacheableGithubResponseStatus ( cls : GitHubCacheClass , status : number ) : boolean {
107+ if ( status === 200 ) return true ;
108+ // Branch-protection permissions are repo/base-branch metadata. Cache stable negative answers too,
109+ // otherwise a missing permission can burn the REST bucket on every PR pass.
110+ return cls === "branch_protection" && ( status === 403 || status === 404 ) ;
111+ }
112+
89113function hasConditionalRequestHeader ( headers : Headers ) : boolean {
90114 return headers . has ( "if-none-match" ) || headers . has ( "if-modified-since" ) || headers . has ( "if-match" ) || headers . has ( "if-unmodified-since" ) ;
91115}
@@ -102,6 +126,30 @@ function recordGitHubCacheMetric(result: "hit" | "miss" | "set" | "coalesced" |
102126 incr ( GITHUB_RESPONSE_CACHE_METRIC , { result, class : cls } ) ;
103127}
104128
129+ function parseRateLimitInt ( value : string | null ) : number | null {
130+ if ( value === null ) return null ;
131+ const parsed = Number ( value ) ;
132+ return Number . isFinite ( parsed ) ? parsed : null ;
133+ }
134+
135+ function observeGitHubRestRateLimit ( url : string , response : Response , admissionKey : GitHubRateLimitAdmissionKey ) : void {
136+ if ( ! url . startsWith ( `${ GITHUB_API_PREFIX } /` ) ) return ;
137+ const resource = response . headers . get ( "x-ratelimit-resource" ) ;
138+ if ( resource !== null && resource !== "core" ) return ;
139+ const remaining = parseRateLimitInt ( response . headers . get ( "x-ratelimit-remaining" ) ) ;
140+ const reset = parseRateLimitInt ( response . headers . get ( "x-ratelimit-reset" ) ) ;
141+ if ( remaining === null || reset === null ) return ;
142+ latestRestRateLimitObservations . set ( admissionKey , {
143+ remaining,
144+ resetAt : new Date ( reset * 1000 ) . toISOString ( ) ,
145+ observedAtMs : Date . now ( ) ,
146+ } ) ;
147+ }
148+
149+ export function latestGitHubRestRateLimitObservation ( admissionKey : GitHubRateLimitAdmissionKey ) : LocalGitHubRestRateLimitObservation | null {
150+ return latestRestRateLimitObservations . get ( admissionKey ) ?? null ;
151+ }
152+
105153async function sha256Short ( value : string ) : Promise < string > {
106154 const digest = await crypto . subtle . digest ( "SHA-256" , new TextEncoder ( ) . encode ( value ) ) ;
107155 return Array . from ( new Uint8Array ( digest ) , ( byte ) => byte . toString ( 16 ) . padStart ( 2 , "0" ) ) . join ( "" ) . slice ( 0 , 16 ) ;
@@ -114,6 +162,25 @@ async function responseCacheKey(url: string, headers: Headers): Promise<string>
114162 return `v2:${ authHash } :${ accept } :${ apiVersion } :${ url } ` ;
115163}
116164
165+ type VolatileSingleFlightScope = { requestKey : string ; authorization : string } ;
166+
167+ function volatileSingleFlightScope ( url : string , headers : Headers ) : VolatileSingleFlightScope {
168+ const accept = encodeURIComponent ( headers . get ( "accept" ) || "" ) ;
169+ const apiVersion = encodeURIComponent ( headers . get ( "x-github-api-version" ) || "" ) ;
170+ return { requestKey : `volatile:${ accept } :${ apiVersion } :${ url } ` , authorization : headers . get ( "authorization" ) || "" } ;
171+ }
172+
173+ function isVolatileSingleFlightEligibleGithubUrl ( url : string , headers : Headers ) : boolean {
174+ if ( ! url . startsWith ( `${ GITHUB_API_PREFIX } /` ) ) return false ;
175+ const accept = ( headers . get ( "accept" ) ?? "" ) . toLowerCase ( ) ;
176+ if ( accept . includes ( "raw" ) || accept . includes ( "text/plain" ) ) return false ;
177+ const path = githubApiPath ( url ) ;
178+ return (
179+ ! / ^ \/ r e p o s \/ [ ^ / ] + \/ [ ^ / ] + \/ c o n t e n t s (?: \/ | $ | [ ? # ] ) / . test ( path ) &&
180+ ! / ^ \/ r e p o s \/ [ ^ / ] + \/ [ ^ / ] + \/ g i t \/ (?: t r e e s | b l o b s ) \/ / . test ( path )
181+ ) ;
182+ }
183+
117184function requestHeaders ( input : RequestInfo | URL , init : RequestInit | undefined ) : Headers {
118185 const headers = new Headers ( typeof Request !== "undefined" && input instanceof Request ? input . headers : undefined ) ;
119186 new Headers ( init ?. headers ) . forEach ( ( value , key ) => headers . set ( key , value ) ) ;
@@ -128,6 +195,22 @@ function requestUrl(input: RequestInfo | URL): string {
128195 return typeof Request !== "undefined" && input instanceof Request ? input . url : String ( input ) ;
129196}
130197
198+ function requestSignal ( input : RequestInfo | URL , init : GitHubTimeoutFetchInit | undefined ) : AbortSignal | undefined {
199+ return init ?. signal ?? ( typeof Request !== "undefined" && input instanceof Request ? input . signal : undefined ) ;
200+ }
201+
202+ function rateLimitAdmissionKey ( init : GitHubTimeoutFetchInit | undefined ) : GitHubRateLimitAdmissionKey | null {
203+ if ( init ?. githubRateLimitAdmission !== true ) return null ;
204+ const key = init . githubRateLimitAdmissionKey ?. trim ( ) ;
205+ return key ? key : null ;
206+ }
207+
208+ function requestInitForFetch ( init : GitHubTimeoutFetchInit | undefined ) : RequestInit | undefined {
209+ if ( ! init || ( ! ( "githubRateLimitAdmission" in init ) && ! ( "githubRateLimitAdmissionKey" in init ) ) ) return init ;
210+ const { githubRateLimitAdmission : _omitted , githubRateLimitAdmissionKey : _omittedKey , ...rest } = init ;
211+ return rest ;
212+ }
213+
131214export function isGitHubResponseCacheReplay ( response : Response ) : boolean {
132215 return response . headers . get ( GITHUB_RESPONSE_CACHE_REPLAY_HEADER ) !== null ;
133216}
@@ -179,15 +262,29 @@ function responseFromCached(hit: CachedGitHubResponse, replayKind: "hit" | "coal
179262 } ) ;
180263}
181264
182- async function fetchWithGitHubRetry ( input : RequestInfo | URL , init ?: RequestInit ) : Promise < Response > {
265+ async function replayableResponse ( response : Response ) : Promise < CachedGitHubResponse > {
266+ return {
267+ status : response . status ,
268+ body : await response . clone ( ) . text ( ) ,
269+ contentType : response . headers . get ( "content-type" ) ?? "application/json" ,
270+ ...( response . headers . get ( "link" ) ? { link : response . headers . get ( "link" ) ! } : { } ) ,
271+ ...( response . headers . get ( "etag" ) ? { etag : response . headers . get ( "etag" ) ! } : { } ) ,
272+ ...( response . headers . get ( "last-modified" ) ? { lastModified : response . headers . get ( "last-modified" ) ! } : { } ) ,
273+ } ;
274+ }
275+
276+ async function fetchWithGitHubRetry ( input : RequestInfo | URL , init ?: GitHubTimeoutFetchInit ) : Promise < Response > {
183277 let response : Response ;
278+ const fetchInit = requestInitForFetch ( init ) ;
279+ const admissionKey = rateLimitAdmissionKey ( init ) ;
184280 for ( let attempt = 0 ; ; attempt += 1 ) {
185- response = init ?. signal
186- ? await fetch ( input , init )
281+ response = fetchInit ?. signal
282+ ? await fetch ( input , fetchInit )
187283 : await fetch ( input , {
188- ...( init ?? { } ) ,
284+ ...( fetchInit ?? { } ) ,
189285 signal : AbortSignal . timeout ( GITHUB_FETCH_TIMEOUT_MS ) ,
190286 } ) ;
287+ if ( admissionKey ) observeGitHubRestRateLimit ( requestUrl ( input ) , response , admissionKey ) ;
191288 // Retry a transient rate-limit (with backoff) instead of surfacing it; stop once exhausted or it's not a limit.
192289 if ( attempt >= GITHUB_RATE_LIMIT_MAX_RETRIES || ! ( await isRateLimitedResponse ( response ) ) ) break ;
193290 await sleep ( rateLimitRetryMs ( response , attempt ) ) ;
@@ -197,22 +294,16 @@ async function fetchWithGitHubRetry(input: RequestInfo | URL, init?: RequestInit
197294
198295async function fetchAndMaybeCacheGitHubGet (
199296 input : RequestInfo | URL ,
200- init : RequestInit | undefined ,
297+ init : GitHubTimeoutFetchInit | undefined ,
201298 url : string ,
202299 cacheKey : string ,
203300 cls : GitHubCacheClass ,
204301) : Promise < { response : Response ; cached : CachedGitHubResponse | null } > {
205302 const response = await fetchWithGitHubRetry ( input , init ) ;
206- if ( response . status !== 200 ) return { response, cached : null } ;
303+ if ( ! isCacheableGithubResponseStatus ( cls , response . status ) ) return { response, cached : null } ;
304+ if ( await isRateLimitedResponse ( response ) ) return { response, cached : null } ;
207305 try {
208- const cached = {
209- status : 200 ,
210- body : await response . clone ( ) . text ( ) ,
211- contentType : response . headers . get ( "content-type" ) ?? "application/json" ,
212- ...( response . headers . get ( "link" ) ? { link : response . headers . get ( "link" ) ! } : { } ) ,
213- ...( response . headers . get ( "etag" ) ? { etag : response . headers . get ( "etag" ) ! } : { } ) ,
214- ...( response . headers . get ( "last-modified" ) ? { lastModified : response . headers . get ( "last-modified" ) ! } : { } ) ,
215- } ;
306+ const cached = await replayableResponse ( response ) ;
216307 await responseCache ! . set ( cacheKey , cached , githubResponseCacheTtlSeconds ( cls ) ) ;
217308 recordGitHubCacheMetric ( "set" , cls ) ;
218309 return { response, cached } ;
@@ -225,15 +316,76 @@ async function fetchAndMaybeCacheGitHubGet(
225316// Single-flight cacheable GETs inside one isolate: a webhook burst often asks for the same metadata
226317// before Redis has been populated. Join those cold misses so GitHub sees one request, then replay the cached body.
227318const inFlightCacheableGets = new Map < string , Promise < CachedGitHubResponse | null > > ( ) ;
319+ // Mutable GitHub GETs are not persisted in Redis, but simultaneous identical reads in one burst can still share the
320+ // leader's response. This dedupes review fan-out without replaying stale CI, PR, label, comment, or event data later.
321+ const inFlightVolatileGets = new Map < string , Map < string , Promise < CachedGitHubResponse | null > > > ( ) ;
322+
323+ async function fetchWithVolatileSingleFlight (
324+ input : RequestInfo | URL ,
325+ init : GitHubTimeoutFetchInit | undefined ,
326+ scope : VolatileSingleFlightScope ,
327+ ) : Promise < Response > {
328+ const existing = inFlightVolatileGets . get ( scope . requestKey ) ?. get ( scope . authorization ) ;
329+ if ( existing ) {
330+ recordGitHubCacheMetric ( "coalesced" , "sensitive" ) ;
331+ const replay = await waitForVolatileReplay ( existing , requestSignal ( input , init ) ) ;
332+ if ( replay ) return responseFromCached ( replay , "coalesced" ) ;
333+ }
334+ let resolveShared ! : ( value : CachedGitHubResponse | null ) => void ;
335+ const shared = new Promise < CachedGitHubResponse | null > ( ( resolve ) => {
336+ resolveShared = resolve ;
337+ } ) ;
338+ let bucket = inFlightVolatileGets . get ( scope . requestKey ) ;
339+ if ( ! bucket ) {
340+ bucket = new Map ( ) ;
341+ inFlightVolatileGets . set ( scope . requestKey , bucket ) ;
342+ }
343+ const sharedWithCleanup = shared . finally ( ( ) => {
344+ const current = inFlightVolatileGets . get ( scope . requestKey ) ;
345+ current ?. delete ( scope . authorization ) ;
346+ if ( current ?. size === 0 ) inFlightVolatileGets . delete ( scope . requestKey ) ;
347+ } ) ;
348+ bucket . set ( scope . authorization , sharedWithCleanup ) ;
349+ recordGitHubCacheMetric ( "bypassed" , "sensitive" ) ;
350+ try {
351+ const response = await fetchWithGitHubRetry ( input , init ) ;
352+ try {
353+ resolveShared ( await replayableResponse ( response ) ) ;
354+ } catch {
355+ resolveShared ( null ) ;
356+ }
357+ return response ;
358+ } catch ( error ) {
359+ resolveShared ( null ) ;
360+ throw error ;
361+ }
362+ }
363+
364+ function abortSignalError ( signal : AbortSignal ) : Error {
365+ return signal . reason instanceof Error ? signal . reason : new Error ( "The operation was aborted." ) ;
366+ }
367+
368+ function waitForVolatileReplay ( shared : Promise < CachedGitHubResponse | null > , signal : AbortSignal | undefined ) : Promise < CachedGitHubResponse | null > {
369+ if ( ! signal ) return shared ;
370+ if ( signal . aborted ) return Promise . reject ( abortSignalError ( signal ) ) ;
371+ return new Promise ( ( resolve , reject ) => {
372+ const onAbort = ( ) => reject ( abortSignalError ( signal ) ) ;
373+ signal . addEventListener ( "abort" , onAbort , { once : true } ) ;
374+ shared . then ( resolve , reject ) . finally ( ( ) => signal . removeEventListener ( "abort" , onAbort ) ) ;
375+ } ) ;
376+ }
228377
229378// A 12s hard cap on every GitHub request. Centralised here so the app token/installation raw fetches plus comment /
230379// label / check-run / pr-action Octokit helpers all inherit the cache boundary, retry, and timeout behavior.
231- export async function timeoutFetch ( input : RequestInfo | URL , init ?: RequestInit ) : Promise < Response > {
380+ export async function timeoutFetch ( input : RequestInfo | URL , init ?: GitHubTimeoutFetchInit ) : Promise < Response > {
232381 const method = requestMethod ( input , init ) ;
233382 const url = requestUrl ( input ) ;
234383 const headers = requestHeaders ( input , init ) ;
235384 const conditional = hasConditionalRequestHeader ( headers ) ;
236385 const cls = method === "GET" && ! conditional ? githubCacheClassForUrl ( url ) : null ;
386+ if ( method === "GET" && ! conditional && cls === null && isVolatileSingleFlightEligibleGithubUrl ( url , headers ) ) {
387+ return fetchWithVolatileSingleFlight ( input , init , volatileSingleFlightScope ( url , headers ) ) ;
388+ }
237389 const useCache = responseCache !== null && cls !== null ;
238390 if ( ! useCache ) {
239391 recordGitHubCacheMetric ( "bypassed" , cacheBypassClass ( method , url , headers ) ) ;
@@ -276,6 +428,8 @@ export async function timeoutFetch(input: RequestInfo | URL, init?: RequestInit)
276428export function clearGitHubResponseCacheForTest ( ) : void {
277429 responseCache = null ;
278430 inFlightCacheableGets . clear ( ) ;
431+ inFlightVolatileGets . clear ( ) ;
432+ latestRestRateLimitObservations . clear ( ) ;
279433}
280434
281435const WRITE_METHODS = new Set ( [ "POST" , "PATCH" , "PUT" , "DELETE" ] ) ;
@@ -330,8 +484,17 @@ export function forcedSelfhostMode(env: { SELFHOST_DEPLOYMENT_MODE?: string | un
330484 * the executor are not double-denied; surface callers (check-run / comment / label) pass the resolved repo mode.
331485 * A SELFHOST_DEPLOYMENT_MODE override beats the per-call mode so the whole instance can be forced non-actuating.
332486 */
333- export function makeInstallationOctokit ( env : Env , token : string , mode : AgentActionMode = "live" ) : Octokit {
334- const octokit = new Octokit ( { auth : token , request : { fetch : timeoutFetch } } ) ;
487+ export function makeInstallationOctokit ( env : Env , token : string , mode : AgentActionMode = "live" , admissionKey ?: GitHubRateLimitAdmissionKey | undefined ) : Octokit {
488+ const octokit = new Octokit ( {
489+ auth : token ,
490+ request : {
491+ fetch : ( input : RequestInfo | URL , init ?: RequestInit ) => {
492+ const fetchInit : GitHubTimeoutFetchInit = Object . assign ( { githubRateLimitAdmission : admissionKey !== undefined } , init ) ;
493+ if ( admissionKey ) fetchInit . githubRateLimitAdmissionKey = admissionKey ;
494+ return timeoutFetch ( input , fetchInit ) ;
495+ } ,
496+ } ,
497+ } ) ;
335498 const effectiveMode = forcedSelfhostMode ( env ) ?? mode ;
336499 if ( effectiveMode !== "live" ) {
337500 octokit . hook . wrap ( "request" , async ( request , options ) => {
0 commit comments