diff --git a/lib/modules/platform/github/index.ts b/lib/modules/platform/github/index.ts index c2513c100010d6d9e7cb874b971c62bf486fff84..e1f37380ba49675cfd88f072f9794d1d41b92cd6 100644 --- a/lib/modules/platform/github/index.ts +++ b/lib/modules/platform/github/index.ts @@ -35,7 +35,10 @@ import type { import * as hostRules from '../../../util/host-rules'; import * as githubHttp from '../../../util/http/github'; import type { GithubHttpOptions } from '../../../util/http/github'; -import type { HttpResponse } from '../../../util/http/types'; +import type { + HttpResponse, + InternalHttpOptions, +} from '../../../util/http/types'; import { coerceObject } from '../../../util/object'; import { regEx } from '../../../util/regex'; import { sanitize } from '../../../util/sanitize'; @@ -316,11 +319,15 @@ export async function getRawFile( branchOrTag?: string, ): Promise<string | null> { const repo = repoName ?? config.repository; + const httpOptions: InternalHttpOptions = { + // Only cache response if it's from the same repo + repoCache: repo === config.repository, + }; let url = `repos/${repo}/contents/${fileName}`; if (branchOrTag) { url += `?ref=` + branchOrTag; } - const res = await githubApi.getJson<{ content: string }>(url); + const res = await githubApi.getJson<{ content: string }>(url, httpOptions); const buf = res.body.content; const str = fromBase64(buf); return str; @@ -1220,7 +1227,7 @@ export async function getIssue( const issueBody = ( await githubApi.getJson<{ body: string }>( `repos/${config.parentRepo ?? config.repository}/issues/${number}`, - { memCache: useCache }, + { memCache: useCache, repoCache: true }, ) ).body.body; return { @@ -1306,6 +1313,7 @@ export async function ensureIssue({ `repos/${config.parentRepo ?? config.repository}/issues/${ issue.number }`, + { repoCache: true }, ) ).body.body; if ( diff --git a/lib/modules/platform/github/pr.ts b/lib/modules/platform/github/pr.ts index 432abe996515c2df911236da6bbb34b087c62359..2cd6bb9359d88d9f3ef4654e6661baf4a94cbdc1 100644 --- a/lib/modules/platform/github/pr.ts +++ b/lib/modules/platform/github/pr.ts @@ -67,6 +67,7 @@ export async function getPrCache( if (pageIdx === 1 && isInitial) { // Speed up initial fetch opts.paginate = true; + opts.repoCache = true; } const perPage = isInitial ? 100 : 20; diff --git a/lib/util/cache/repository/types.ts b/lib/util/cache/repository/types.ts index eabbaba5f2ca5effaf36d386c0dda4f3392264ea..68d915d30da974a6df1883a449e0cc5030054a8c 100644 --- a/lib/util/cache/repository/types.ts +++ b/lib/util/cache/repository/types.ts @@ -8,6 +8,7 @@ import type { BitbucketPrCacheData } from '../../../modules/platform/bitbucket/t import type { GiteaPrCacheData } from '../../../modules/platform/gitea/types'; import type { RepoInitConfig } from '../../../workers/repository/init/types'; import type { PrBlockedBy } from '../../../workers/types'; +import type { HttpResponse } from '../../http/types'; export interface BaseBranchCache { sha: string; // branch commit sha @@ -124,8 +125,15 @@ export interface BranchCache { result?: string; } +export interface HttpCache { + etag: string; + httpResponse: HttpResponse<unknown>; + timeStamp: string; +} + export interface RepoCacheData { configFileName?: string; + httpCache?: Record<string, HttpCache>; semanticCommits?: 'enabled' | 'disabled'; branches?: BranchCache[]; init?: RepoInitConfig; diff --git a/lib/util/http/index.spec.ts b/lib/util/http/index.spec.ts index eb9aadd63bac5b55371cc6e92e4cba7ee1e528d3..a48769c6aa58a5699d6fd0750b0c3f2112ea672d 100644 --- a/lib/util/http/index.spec.ts +++ b/lib/util/http/index.spec.ts @@ -76,13 +76,38 @@ describe('util/http/index', () => { }, }) .get('/') - .reply(200, '{ "test": true }'); - expect(await http.getJson('http://renovate.com')).toEqual({ + .reply(200, '{ "test": true }', { etag: 'abc123' }); + expect( + await http.getJson('http://renovate.com', { repoCache: true }), + ).toEqual({ authorization: false, body: { test: true, }, - headers: {}, + headers: { + etag: 'abc123', + }, + statusCode: 200, + }); + + httpMock + .scope(baseUrl, { + reqheaders: { + accept: 'application/json', + }, + }) + .get('/') + .reply(304, '', { etag: 'abc123' }); + expect( + await http.getJson('http://renovate.com', { repoCache: true }), + ).toEqual({ + authorization: false, + body: { + test: true, + }, + headers: { + etag: 'abc123', + }, statusCode: 200, }); }); diff --git a/lib/util/http/index.ts b/lib/util/http/index.ts index c675e378f5692e1d15ce6e8eb4b3cd6fafb95548..40881e4309777bc1cd7651da2d570c4585f0d6fa 100644 --- a/lib/util/http/index.ts +++ b/lib/util/http/index.ts @@ -7,6 +7,7 @@ import { pkg } from '../../expose.cjs'; import { logger } from '../../logger'; import { ExternalHostError } from '../../types/errors/external-host-error'; import * as memCache from '../cache/memory'; +import { getCache } from '../cache/repository'; import { clone } from '../clone'; import { hash } from '../hash'; import { type AsyncResult, Result } from '../result'; @@ -163,6 +164,8 @@ export class Http<Opts extends HttpOptions = HttpOptions> { httpOptions, ); + logger.trace(`HTTP request: ${options.method.toUpperCase()} ${url}`); + const etagCache = httpOptions.etagCache && options.method === 'get' ? httpOptions.etagCache @@ -210,6 +213,16 @@ export class Http<Opts extends HttpOptions = HttpOptions> { // istanbul ignore else: no cache tests if (!resPromise) { + if (httpOptions.repoCache) { + const cachedEtag = getCache().httpCache?.[url]?.etag; + if (cachedEtag) { + logger.debug(`Using cached etag for ${url}`); + options.headers = { + ...options.headers, + 'If-None-Match': cachedEtag, + }; + } + } const startTime = Date.now(); const httpTask: GotTask<T> = () => { const queueDuration = Date.now() - startTime; @@ -243,6 +256,31 @@ export class Http<Opts extends HttpOptions = HttpOptions> { const deepCopyNeeded = !!memCacheKey && res.statusCode !== 304; const resCopy = copyResponse(res, deepCopyNeeded); resCopy.authorization = !!options?.headers?.authorization; + if (httpOptions.repoCache) { + const cache = getCache(); + cache.httpCache ??= {}; + if (resCopy.statusCode === 200 && resCopy.headers?.etag) { + logger.debug( + `Saving response to cache: ${url} with etag ${resCopy.headers.etag}`, + ); + cache.httpCache[url] = { + etag: resCopy.headers.etag, + httpResponse: copyResponse(res, deepCopyNeeded), + timeStamp: new Date().toISOString(), + }; + } + if (resCopy.statusCode === 304 && cache.httpCache[url]?.httpResponse) { + logger.debug( + `Using cached response: ${url} with etag ${resCopy.headers.etag} from ${cache.httpCache[url].timeStamp}`, + ); + const cacheCopy = copyResponse( + cache.httpCache[url].httpResponse, + deepCopyNeeded, + ); + cacheCopy.authorization = !!options?.headers?.authorization; + return cacheCopy as HttpResponse<T>; + } + } return resCopy; } catch (err) { const { abortOnError, abortIgnoreStatusCodes } = options; diff --git a/lib/util/http/types.ts b/lib/util/http/types.ts index 5d3d2770a29d0f5b122276564253aeedabb9b16b..5969aeade45806611eed62a959c773ab546b5870 100644 --- a/lib/util/http/types.ts +++ b/lib/util/http/types.ts @@ -65,6 +65,7 @@ export interface HttpOptions { token?: string; memCache?: boolean; + repoCache?: boolean; } export interface EtagCache<T = any> { @@ -81,6 +82,7 @@ export interface InternalHttpOptions extends HttpOptions { responseType?: 'json' | 'buffer'; method?: 'get' | 'post' | 'put' | 'patch' | 'delete' | 'head'; parseJson?: ParseJsonFunction; + repoCache?: boolean; } export interface HttpHeaders extends IncomingHttpHeaders {