feat(http): Add pluggable HTTP cache implementation (#27998)

This commit is contained in:
Sergei Zharinov 2024-03-19 11:49:59 -03:00 committed by GitHub
parent b900884825
commit 4f70ff15cd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 391 additions and 41 deletions

View file

@ -6,7 +6,6 @@ import type {
import type { PackageFile } from '../../../modules/manager/types'; import type { PackageFile } from '../../../modules/manager/types';
import type { RepoInitConfig } from '../../../workers/repository/init/types'; import type { RepoInitConfig } from '../../../workers/repository/init/types';
import type { PrBlockedBy } from '../../../workers/types'; import type { PrBlockedBy } from '../../../workers/types';
import type { HttpResponse } from '../../http/types';
export interface BaseBranchCache { export interface BaseBranchCache {
sha: string; // branch commit sha sha: string; // branch commit sha
@ -123,16 +122,9 @@ export interface BranchCache {
result?: string; result?: string;
} }
export interface HttpCache {
etag?: string;
httpResponse: HttpResponse<unknown>;
lastModified?: string;
timeStamp: string;
}
export interface RepoCacheData { export interface RepoCacheData {
configFileName?: string; configFileName?: string;
httpCache?: Record<string, HttpCache>; httpCache?: Record<string, unknown>;
semanticCommits?: 'enabled' | 'disabled'; semanticCommits?: 'enabled' | 'disabled';
branches?: BranchCache[]; branches?: BranchCache[];
init?: RepoInitConfig; init?: RepoInitConfig;

View file

@ -0,0 +1,94 @@
import { logger } from '../../../logger';
import { HttpCacheStats } from '../../stats';
import type { GotOptions, HttpResponse } from '../types';
import { copyResponse } from '../util';
import { HttpCacheSchema } from './schema';
import type { HttpCache, HttpCacheProvider } from './types';
export abstract class AbstractHttpCacheProvider implements HttpCacheProvider {
protected abstract load(url: string): Promise<unknown>;
protected abstract persist(url: string, data: HttpCache): Promise<void>;
async get(url: string): Promise<HttpCache | null> {
const cache = await this.load(url);
const httpCache = HttpCacheSchema.parse(cache);
if (!httpCache) {
return null;
}
return httpCache as HttpCache;
}
async setCacheHeaders<T extends Pick<GotOptions, 'headers'>>(
url: string,
opts: T,
): Promise<void> {
const httpCache = await this.get(url);
if (!httpCache) {
return;
}
opts.headers ??= {};
if (httpCache.etag) {
opts.headers['If-None-Match'] = httpCache.etag;
}
if (httpCache.lastModified) {
opts.headers['If-Modified-Since'] = httpCache.lastModified;
}
}
async wrapResponse<T>(
url: string,
resp: HttpResponse<T>,
): Promise<HttpResponse<T>> {
if (resp.statusCode === 200) {
const etag = resp.headers?.['etag'];
const lastModified = resp.headers?.['last-modified'];
HttpCacheStats.incRemoteMisses(url);
const httpResponse = copyResponse(resp, true);
const timestamp = new Date().toISOString();
const newHttpCache = HttpCacheSchema.parse({
etag,
lastModified,
httpResponse,
timestamp,
});
if (newHttpCache) {
logger.debug(
`http cache: saving ${url} (etag=${etag}, lastModified=${lastModified})`,
);
await this.persist(url, newHttpCache as HttpCache);
} else {
logger.debug(`http cache: failed to persist cache for ${url}`);
}
return resp;
}
if (resp.statusCode === 304) {
const httpCache = await this.get(url);
if (!httpCache) {
return resp;
}
const timestamp = httpCache.timestamp;
logger.debug(
`http cache: Using cached response: ${url} from ${timestamp}`,
);
HttpCacheStats.incRemoteHits(url);
const cachedResp = copyResponse(
httpCache.httpResponse as HttpResponse<T>,
true,
);
cachedResp.authorization = resp.authorization;
return cachedResp;
}
return resp;
}
}

View file

@ -0,0 +1,152 @@
import { Http } from '..';
import * as httpMock from '../../../../test/http-mock';
import { logger } from '../../../../test/util';
import { getCache, resetCache } from '../../cache/repository';
import { repoCacheProvider } from './repository-http-cache-provider';
describe('util/http/cache/repository-http-cache-provider', () => {
beforeEach(() => {
resetCache();
});
const http = new Http('test', {
cacheProvider: repoCacheProvider,
});
it('reuses data with etag', async () => {
const scope = httpMock.scope('https://example.com');
scope.get('/foo/bar').reply(200, { msg: 'Hello, world!' }, { etag: '123' });
const res1 = await http.getJson('https://example.com/foo/bar');
expect(res1).toMatchObject({
statusCode: 200,
body: { msg: 'Hello, world!' },
authorization: false,
});
scope.get('/foo/bar').reply(304);
const res2 = await http.getJson('https://example.com/foo/bar');
expect(res2).toMatchObject({
statusCode: 200,
body: { msg: 'Hello, world!' },
authorization: false,
});
});
it('reuses data with last-modified', async () => {
const scope = httpMock.scope('https://example.com');
scope
.get('/foo/bar')
.reply(
200,
{ msg: 'Hello, world!' },
{ 'last-modified': 'Mon, 01 Jan 2000 00:00:00 GMT' },
);
const res1 = await http.getJson('https://example.com/foo/bar');
expect(res1).toMatchObject({
statusCode: 200,
body: { msg: 'Hello, world!' },
authorization: false,
});
scope.get('/foo/bar').reply(304);
const res2 = await http.getJson('https://example.com/foo/bar');
expect(res2).toMatchObject({
statusCode: 200,
body: { msg: 'Hello, world!' },
authorization: false,
});
});
it('uses older cache format', async () => {
const repoCache = getCache();
repoCache.httpCache = {
'https://example.com/foo/bar': {
etag: '123',
lastModified: 'Mon, 01 Jan 2000 00:00:00 GMT',
httpResponse: { statusCode: 200, body: { msg: 'Hello, world!' } },
timeStamp: new Date().toISOString(),
},
};
httpMock.scope('https://example.com').get('/foo/bar').reply(304);
const res = await http.getJson('https://example.com/foo/bar');
expect(res).toMatchObject({
statusCode: 200,
body: { msg: 'Hello, world!' },
authorization: false,
});
});
it('reports if cache could not be persisted', async () => {
httpMock
.scope('https://example.com')
.get('/foo/bar')
.reply(200, { msg: 'Hello, world!' });
await http.getJson('https://example.com/foo/bar');
expect(logger.logger.debug).toHaveBeenCalledWith(
'http cache: failed to persist cache for https://example.com/foo/bar',
);
});
it('handles abrupt cache reset', async () => {
const scope = httpMock.scope('https://example.com');
scope.get('/foo/bar').reply(200, { msg: 'Hello, world!' }, { etag: '123' });
const res1 = await http.getJson('https://example.com/foo/bar');
expect(res1).toMatchObject({
statusCode: 200,
body: { msg: 'Hello, world!' },
authorization: false,
});
resetCache();
scope.get('/foo/bar').reply(304);
const res2 = await http.getJson('https://example.com/foo/bar');
expect(res2).toMatchObject({
statusCode: 304,
authorization: false,
});
});
it('bypasses for statuses other than 200 and 304', async () => {
const scope = httpMock.scope('https://example.com');
scope.get('/foo/bar').reply(203);
const res = await http.getJson('https://example.com/foo/bar');
expect(res).toMatchObject({
statusCode: 203,
authorization: false,
});
});
it('supports authorization', async () => {
const scope = httpMock.scope('https://example.com');
scope.get('/foo/bar').reply(200, { msg: 'Hello, world!' }, { etag: '123' });
const res1 = await http.getJson('https://example.com/foo/bar', {
headers: { authorization: 'Bearer 123' },
});
expect(res1).toMatchObject({
statusCode: 200,
body: { msg: 'Hello, world!' },
authorization: true,
});
scope.get('/foo/bar').reply(304);
const res2 = await http.getJson('https://example.com/foo/bar', {
headers: { authorization: 'Bearer 123' },
});
expect(res2).toMatchObject({
statusCode: 200,
body: { msg: 'Hello, world!' },
authorization: true,
});
});
});

View file

@ -0,0 +1,20 @@
import { getCache } from '../../cache/repository';
import { AbstractHttpCacheProvider } from './abstract-http-cache-provider';
import type { HttpCache } from './types';
export class RepositoryHttpCacheProvider extends AbstractHttpCacheProvider {
override load(url: string): Promise<unknown> {
const cache = getCache();
cache.httpCache ??= {};
return Promise.resolve(cache.httpCache[url]);
}
override persist(url: string, data: HttpCache): Promise<void> {
const cache = getCache();
cache.httpCache ??= {};
cache.httpCache[url] = data;
return Promise.resolve();
}
}
export const repoCacheProvider = new RepositoryHttpCacheProvider();

34
lib/util/http/cache/schema.ts vendored Normal file
View file

@ -0,0 +1,34 @@
import { z } from 'zod';
const invalidFieldsMsg =
'Cache object should have `etag` or `lastModified` fields';
export const HttpCacheSchema = z
.object({
// TODO: remove this migration part during the Christmas eve 2024
timeStamp: z.string().optional(),
timestamp: z.string().optional(),
})
.passthrough()
.transform((data) => {
if (data.timeStamp) {
data.timestamp = data.timeStamp;
delete data.timeStamp;
}
return data;
})
.pipe(
z
.object({
etag: z.string().optional(),
lastModified: z.string().optional(),
httpResponse: z.unknown(),
timestamp: z.string(),
})
.refine(
({ etag, lastModified }) => etag ?? lastModified,
invalidFieldsMsg,
),
)
.nullable()
.catch(null);

17
lib/util/http/cache/types.ts vendored Normal file
View file

@ -0,0 +1,17 @@
import type { GotOptions, HttpResponse } from '../types';
export interface HttpCache {
etag?: string;
lastModified?: string;
httpResponse: unknown;
timestamp: string;
}
export interface HttpCacheProvider {
setCacheHeaders<T extends Pick<GotOptions, 'headers'>>(
url: string,
opts: T,
): Promise<void>;
wrapResponse<T>(url: string, resp: HttpResponse<T>): Promise<HttpResponse<T>>;
}

View file

@ -1,3 +1,4 @@
import is from '@sindresorhus/is';
import merge from 'deepmerge'; import merge from 'deepmerge';
import got, { Options, RequestError } from 'got'; import got, { Options, RequestError } from 'got';
import type { SetRequired } from 'type-fest'; import type { SetRequired } from 'type-fest';
@ -8,7 +9,6 @@ import { logger } from '../../logger';
import { ExternalHostError } from '../../types/errors/external-host-error'; import { ExternalHostError } from '../../types/errors/external-host-error';
import * as memCache from '../cache/memory'; import * as memCache from '../cache/memory';
import { getCache } from '../cache/repository'; import { getCache } from '../cache/repository';
import { clone } from '../clone';
import { hash } from '../hash'; import { hash } from '../hash';
import { type AsyncResult, Result } from '../result'; import { type AsyncResult, Result } from '../result';
import { import {
@ -27,12 +27,14 @@ import type {
GotJSONOptions, GotJSONOptions,
GotOptions, GotOptions,
GotTask, GotTask,
HttpCache,
HttpOptions, HttpOptions,
HttpResponse, HttpResponse,
InternalHttpOptions, InternalHttpOptions,
} from './types'; } from './types';
// TODO: refactor code to remove this (#9651) // TODO: refactor code to remove this (#9651)
import './legacy'; import './legacy';
import { copyResponse } from './util';
export { RequestError as HttpError }; export { RequestError as HttpError };
@ -49,26 +51,6 @@ type JsonArgs<
schema?: Schema; schema?: Schema;
}; };
// Copying will help to avoid circular structure
// and mutation of the cached response.
function copyResponse<T>(
response: HttpResponse<T>,
deep: boolean,
): HttpResponse<T> {
const { body, statusCode, headers } = response;
return deep
? {
statusCode,
body: body instanceof Buffer ? (body.subarray() as T) : clone<T>(body),
headers: clone(headers),
}
: {
statusCode,
body,
headers,
};
}
function applyDefaultHeaders(options: Options): void { function applyDefaultHeaders(options: Options): void {
const renovateVersion = pkg.version; const renovateVersion = pkg.version;
options.headers = { options.headers = {
@ -142,13 +124,17 @@ export class Http<Opts extends HttpOptions = HttpOptions> {
options: HttpOptions = {}, options: HttpOptions = {},
) { ) {
const retryLimit = process.env.NODE_ENV === 'test' ? 0 : 2; const retryLimit = process.env.NODE_ENV === 'test' ? 0 : 2;
this.options = merge<GotOptions>(options, { this.options = merge<GotOptions>(
context: { hostType }, options,
retry: { {
limit: retryLimit, context: { hostType },
maxRetryAfter: 0, // Don't rely on `got` retry-after handling, just let it fail and then we'll handle it retry: {
limit: retryLimit,
maxRetryAfter: 0, // Don't rely on `got` retry-after handling, just let it fail and then we'll handle it
},
}, },
}); { isMergeableObject: is.plainObject },
);
} }
protected getThrottle(url: string): Throttle | null { protected getThrottle(url: string): Throttle | null {
@ -164,13 +150,14 @@ export class Http<Opts extends HttpOptions = HttpOptions> {
url = resolveBaseUrl(httpOptions.baseUrl, url); url = resolveBaseUrl(httpOptions.baseUrl, url);
} }
let options = merge<SetRequired<GotOptions, 'method'>, GotOptions>( let options = merge<SetRequired<GotOptions, 'method'>, InternalHttpOptions>(
{ {
method: 'get', method: 'get',
...this.options, ...this.options,
hostType: this.hostType, hostType: this.hostType,
}, },
httpOptions, httpOptions,
{ isMergeableObject: is.plainObject },
); );
logger.trace(`HTTP request: ${options.method.toUpperCase()} ${url}`); logger.trace(`HTTP request: ${options.method.toUpperCase()} ${url}`);
@ -212,7 +199,9 @@ export class Http<Opts extends HttpOptions = HttpOptions> {
// istanbul ignore else: no cache tests // istanbul ignore else: no cache tests
if (!resPromise) { if (!resPromise) {
if (httpOptions.repoCache) { if (httpOptions.repoCache) {
const responseCache = getCache().httpCache?.[url]; const responseCache = getCache().httpCache?.[url] as
| HttpCache
| undefined;
// Prefer If-Modified-Since over If-None-Match // Prefer If-Modified-Since over If-None-Match
if (responseCache?.['lastModified']) { if (responseCache?.['lastModified']) {
logger.debug( logger.debug(
@ -232,6 +221,11 @@ export class Http<Opts extends HttpOptions = HttpOptions> {
}; };
} }
} }
if (options.cacheProvider) {
await options.cacheProvider.setCacheHeaders(url, options);
}
const startTime = Date.now(); const startTime = Date.now();
const httpTask: GotTask<T> = () => { const httpTask: GotTask<T> = () => {
const queueMs = Date.now() - startTime; const queueMs = Date.now() - startTime;
@ -261,6 +255,7 @@ export class Http<Opts extends HttpOptions = HttpOptions> {
const deepCopyNeeded = !!memCacheKey && res.statusCode !== 304; const deepCopyNeeded = !!memCacheKey && res.statusCode !== 304;
const resCopy = copyResponse(res, deepCopyNeeded); const resCopy = copyResponse(res, deepCopyNeeded);
resCopy.authorization = !!options?.headers?.authorization; resCopy.authorization = !!options?.headers?.authorization;
if (httpOptions.repoCache) { if (httpOptions.repoCache) {
const cache = getCache(); const cache = getCache();
cache.httpCache ??= {}; cache.httpCache ??= {};
@ -279,19 +274,25 @@ export class Http<Opts extends HttpOptions = HttpOptions> {
timeStamp: new Date().toISOString(), timeStamp: new Date().toISOString(),
}; };
} }
if (resCopy.statusCode === 304 && cache.httpCache[url]?.httpResponse) { const httpCache = cache.httpCache[url] as HttpCache | undefined;
if (resCopy.statusCode === 304 && httpCache) {
logger.debug( logger.debug(
`http cache: Using cached response: ${url} from ${cache.httpCache[url].timeStamp}`, `http cache: Using cached response: ${url} from ${httpCache.timeStamp}`,
); );
HttpCacheStats.incRemoteHits(url); HttpCacheStats.incRemoteHits(url);
const cacheCopy = copyResponse( const cacheCopy = copyResponse(
cache.httpCache[url].httpResponse, httpCache.httpResponse,
deepCopyNeeded, deepCopyNeeded,
); );
cacheCopy.authorization = !!options?.headers?.authorization; cacheCopy.authorization = !!options?.headers?.authorization;
return cacheCopy as HttpResponse<T>; return cacheCopy as HttpResponse<T>;
} }
} }
if (options.cacheProvider) {
return await options.cacheProvider.wrapResponse(url, resCopy);
}
return resCopy; return resCopy;
} catch (err) { } catch (err) {
const { abortOnError, abortIgnoreStatusCodes } = options; const { abortOnError, abortIgnoreStatusCodes } = options;

View file

@ -4,6 +4,7 @@ import type {
OptionsOfJSONResponseBody, OptionsOfJSONResponseBody,
ParseJsonFunction, ParseJsonFunction,
} from 'got'; } from 'got';
import type { HttpCacheProvider } from './cache/types';
export type GotContextOptions = { export type GotContextOptions = {
authType?: string; authType?: string;
@ -65,7 +66,11 @@ export interface HttpOptions {
token?: string; token?: string;
memCache?: boolean; memCache?: boolean;
/**
* @deprecated
*/
repoCache?: boolean; repoCache?: boolean;
cacheProvider?: HttpCacheProvider;
} }
export interface InternalHttpOptions extends HttpOptions { export interface InternalHttpOptions extends HttpOptions {
@ -73,6 +78,9 @@ export interface InternalHttpOptions extends HttpOptions {
responseType?: 'json' | 'buffer'; responseType?: 'json' | 'buffer';
method?: 'get' | 'post' | 'put' | 'patch' | 'delete' | 'head'; method?: 'get' | 'post' | 'put' | 'patch' | 'delete' | 'head';
parseJson?: ParseJsonFunction; parseJson?: ParseJsonFunction;
/**
* @deprecated
*/
repoCache?: boolean; repoCache?: boolean;
} }
@ -89,3 +97,13 @@ export interface HttpResponse<T = string> {
export type Task<T> = () => Promise<T>; export type Task<T> = () => Promise<T>;
export type GotTask<T> = Task<HttpResponse<T>>; export type GotTask<T> = Task<HttpResponse<T>>;
/**
* @deprecated
*/
export interface HttpCache {
etag?: string;
httpResponse: HttpResponse<unknown>;
lastModified?: string;
timeStamp: string;
}

22
lib/util/http/util.ts Normal file
View file

@ -0,0 +1,22 @@
import { clone } from '../clone';
import type { HttpResponse } from './types';
// Copying will help to avoid circular structure
// and mutation of the cached response.
export function copyResponse<T>(
response: HttpResponse<T>,
deep: boolean,
): HttpResponse<T> {
const { body, statusCode, headers } = response;
return deep
? {
statusCode,
body: body instanceof Buffer ? (body.subarray() as T) : clone<T>(body),
headers: clone(headers),
}
: {
statusCode,
body,
headers,
};
}