0
0
Fork 0
mirror of https://github.com/renovatebot/renovate.git synced 2025-03-15 08:34:54 +00:00
renovatebot_renovate/lib/util/http/http.ts
2025-03-05 08:18:41 +00:00

649 lines
18 KiB
TypeScript

import is from '@sindresorhus/is';
import merge from 'deepmerge';
import type { Options, RetryObject } from 'got';
import type { Merge, SetRequired } from 'type-fest';
import type { infer as Infer } from 'zod';
import { ZodType } from 'zod';
import { GlobalConfig } from '../../config/global';
import { HOST_DISABLED } from '../../constants/error-messages';
import { pkg } from '../../expose.cjs';
import { logger } from '../../logger';
import { ExternalHostError } from '../../types/errors/external-host-error';
import * as memCache from '../cache/memory';
import { hash } from '../hash';
import { type AsyncResult, Result } from '../result';
import { isHttpUrl, parseUrl, resolveBaseUrl } from '../url';
import { parseSingleYaml } from '../yaml';
import { applyAuthorization, removeAuthorization } from './auth';
import { fetch, stream } from './got';
import { applyHostRule, findMatchingRule } from './host-rules';
import { getQueue } from './queue';
import { getRetryAfter, wrapWithRetry } from './retry-after';
import { getThrottle } from './throttle';
import type {
GotOptions,
GotStreamOptions,
GotTask,
HttpMethod,
HttpOptions,
HttpResponse,
SafeJsonError,
} from './types';
import { copyResponse } from './util';
export interface InternalJsonUnsafeOptions<
Opts extends HttpOptions = HttpOptions,
> {
url: string | URL;
httpOptions?: Opts;
}
export interface InternalJsonOptions<
Opts extends HttpOptions,
ResT = unknown,
Schema extends ZodType<ResT> = ZodType<ResT>,
> extends InternalJsonUnsafeOptions<Opts> {
schema?: Schema;
}
export type InternalGotOptions = SetRequired<GotOptions, 'method' | 'context'>;
export interface InternalHttpOptions extends HttpOptions {
json?: HttpOptions['body'];
method?: HttpMethod;
parseJson?: Options['parseJson'];
}
export function applyDefaultHeaders(options: Options): void {
const renovateVersion = pkg.version;
options.headers = {
...options.headers,
'user-agent':
GlobalConfig.get('userAgent') ??
`RenovateBot/${renovateVersion} (https://github.com/renovatebot/renovate)`,
};
}
export abstract class HttpBase<
JSONOpts extends HttpOptions = HttpOptions,
Opts extends HttpOptions = HttpOptions,
> {
private readonly options: InternalGotOptions;
protected get baseUrl(): string | undefined {
return undefined;
}
constructor(
protected hostType: string,
options: HttpOptions = {},
) {
const retryLimit = process.env.NODE_ENV === 'test' ? 0 : 2;
this.options = merge<InternalGotOptions>(
options,
{
method: 'get',
context: { hostType },
retry: {
calculateDelay: (retryObject) =>
this.calculateRetryDelay(retryObject),
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 },
);
}
private async request(
requestUrl: string | URL,
httpOptions: InternalHttpOptions,
): Promise<HttpResponse<string>>;
private async request(
requestUrl: string | URL,
httpOptions: InternalHttpOptions & { responseType: 'text' },
): Promise<HttpResponse<string>>;
private async request(
requestUrl: string | URL,
httpOptions: InternalHttpOptions & { responseType: 'buffer' },
): Promise<HttpResponse<Buffer>>;
private async request<T = unknown>(
requestUrl: string | URL,
httpOptions: InternalHttpOptions & { responseType: 'json' },
): Promise<HttpResponse<T>>;
private async request(
requestUrl: string | URL,
httpOptions: InternalHttpOptions,
): Promise<HttpResponse<unknown>> {
const resolvedUrl = this.resolveUrl(requestUrl, httpOptions);
const url = resolvedUrl.toString();
this.processOptions(resolvedUrl, httpOptions);
let options = merge<InternalGotOptions, InternalHttpOptions>(
{
...this.options,
hostType: this.hostType,
},
httpOptions,
{ isMergeableObject: is.plainObject },
);
logger.trace(`HTTP request: ${options.method.toUpperCase()} ${url}`);
options.hooks = {
beforeRedirect: [removeAuthorization],
};
applyDefaultHeaders(options);
if (
is.undefined(options.readOnly) &&
['head', 'get'].includes(options.method)
) {
options.readOnly = true;
}
const hostRule = findMatchingRule(url, options);
options = applyHostRule(url, options, hostRule);
if (options.enabled === false) {
logger.debug(`Host is disabled - rejecting request. HostUrl: ${url}`);
throw new Error(HOST_DISABLED);
}
options = applyAuthorization(options);
options.timeout ??= 60000;
const { cacheProvider } = options;
const cachedResponse = await cacheProvider?.bypassServer<string | Buffer>(
url,
);
if (cachedResponse) {
return cachedResponse;
}
const memCacheKey =
options.memCache !== false &&
(options.method === 'get' || options.method === 'head')
? hash(
`got-${JSON.stringify({
url,
headers: options.headers,
method: options.method,
})}`,
)
: null;
let resPromise: Promise<HttpResponse<unknown>> | null = null;
// Cache GET requests unless memCache=false
if (memCacheKey) {
resPromise = memCache.get(memCacheKey);
}
if (!resPromise) {
if (cacheProvider) {
await cacheProvider.setCacheHeaders(url, options);
}
const startTime = Date.now();
const httpTask: GotTask = () => {
const queueMs = Date.now() - startTime;
return fetch(url, options, { queueMs });
};
const throttle = getThrottle(url);
const throttledTask = throttle ? () => throttle.add(httpTask) : httpTask;
const queue = getQueue(url);
const queuedTask = queue ? () => queue.add(throttledTask) : throttledTask;
const { maxRetryAfter = 60 } = hostRule;
resPromise = wrapWithRetry(queuedTask, url, getRetryAfter, maxRetryAfter);
if (memCacheKey) {
memCache.set(memCacheKey, resPromise);
}
}
try {
const res = await resPromise;
const deepCopyNeeded = !!memCacheKey && res.statusCode !== 304;
const resCopy = copyResponse(res, deepCopyNeeded);
resCopy.authorization = !!options?.headers?.authorization;
if (cacheProvider) {
return await cacheProvider.wrapServerResponse(url, resCopy);
}
return resCopy;
} catch (err) {
const { abortOnError, abortIgnoreStatusCodes } = options;
if (abortOnError && !abortIgnoreStatusCodes?.includes(err.statusCode)) {
throw new ExternalHostError(err);
}
const staleResponse = await cacheProvider?.bypassServer<string | Buffer>(
url,
true,
);
if (staleResponse) {
logger.debug(
{ err },
`Request error: returning stale cache instead for ${url}`,
);
return staleResponse;
}
this.handleError(requestUrl, httpOptions, err);
}
}
protected processOptions(_url: URL, _options: InternalHttpOptions): void {
// noop
}
protected handleError(
_url: string | URL,
_httpOptions: HttpOptions,
err: Error,
): never {
throw err;
}
protected resolveUrl(
requestUrl: string | URL,
options: HttpOptions | undefined,
): URL {
let url = requestUrl;
if (url instanceof URL) {
// already a aboslute URL
return url;
}
const baseUrl = options?.baseUrl ?? this.baseUrl;
if (baseUrl) {
url = resolveBaseUrl(baseUrl, url);
}
const parsedUrl = parseUrl(url);
if (!parsedUrl || !isHttpUrl(parsedUrl)) {
logger.error(
{ url: requestUrl, baseUrl, resolvedUrl: url },
'Request Error: cannot parse url',
);
throw new Error('Invalid URL');
}
return parsedUrl;
}
protected calculateRetryDelay({ computedValue }: RetryObject): number {
return computedValue;
}
get(
url: string,
options: HttpOptions = {},
): Promise<HttpResponse<string | Buffer>> {
return this.request(url, options);
}
head(url: string, options: HttpOptions = {}): Promise<HttpResponse<never>> {
// to complex to validate
return this.request(url, {
...options,
responseType: 'text',
method: 'head',
}) as Promise<HttpResponse<never>>;
}
getText(
url: string | URL,
options: HttpOptions = {},
): Promise<HttpResponse<string>> {
return this.request(url, { ...options, responseType: 'text' });
}
getBuffer(
url: string | URL,
options: HttpOptions = {},
): Promise<HttpResponse<Buffer>> {
return this.request(url, { ...options, responseType: 'buffer' });
}
protected requestJsonUnsafe<ResT>(
method: HttpMethod,
{ url, httpOptions: requestOptions }: InternalJsonUnsafeOptions<JSONOpts>,
): Promise<HttpResponse<ResT>> {
const { body: json, ...httpOptions } = { ...requestOptions };
const opts: InternalHttpOptions = {
...httpOptions,
method,
};
// signal that we expect a json response
opts.headers = {
accept: 'application/json',
...opts.headers,
};
if (json) {
opts.json = json;
}
return this.request<ResT>(url, { ...opts, responseType: 'json' });
}
private async requestJson<ResT, Schema extends ZodType<ResT> = ZodType<ResT>>(
method: HttpMethod,
options: InternalJsonOptions<JSONOpts, ResT, Schema>,
): Promise<HttpResponse<ResT>> {
const res = await this.requestJsonUnsafe<ResT>(method, options);
if (options.schema) {
res.body = await options.schema.parseAsync(res.body);
}
return res;
}
private resolveArgs<ResT = unknown>(
arg1: string,
arg2: JSONOpts | ZodType<ResT> | undefined,
arg3: ZodType<ResT> | undefined,
): InternalJsonOptions<JSONOpts, ResT> {
const res: InternalJsonOptions<JSONOpts, ResT> = { url: arg1 };
if (arg2 instanceof ZodType) {
res.schema = arg2;
} else if (arg2) {
res.httpOptions = arg2;
}
if (arg3) {
res.schema = arg3;
}
return res;
}
async getPlain(url: string, options?: Opts): Promise<HttpResponse> {
const opt = options ?? {};
return await this.getText(url, {
headers: {
Accept: 'text/plain',
},
...opt,
});
}
/**
* @deprecated use `getYaml` instead
*/
async getYamlUnchecked<ResT>(
url: string,
options?: Opts,
): Promise<HttpResponse<ResT>> {
const res = await this.getText(url, options);
const body = parseSingleYaml<ResT>(res.body);
return { ...res, body };
}
async getYaml<Schema extends ZodType<any, any, any>>(
url: string,
schema: Schema,
): Promise<HttpResponse<Infer<Schema>>>;
async getYaml<Schema extends ZodType<any, any, any>>(
url: string,
options: Opts,
schema: Schema,
): Promise<HttpResponse<Infer<Schema>>>;
async getYaml<Schema extends ZodType<any, any, any>>(
arg1: string,
arg2?: Opts | Schema,
arg3?: Schema,
): Promise<HttpResponse<Infer<Schema>>> {
const url = arg1;
let schema: Schema;
let httpOptions: Opts | undefined;
if (arg3) {
schema = arg3;
httpOptions = arg2 as Opts;
} else {
schema = arg2 as Schema;
}
const opts: InternalHttpOptions = {
...httpOptions,
method: 'get',
};
const res = await this.getText(url, opts);
const body = await schema.parseAsync(parseSingleYaml(res.body));
return { ...res, body };
}
getYamlSafe<
ResT extends NonNullable<unknown>,
Schema extends ZodType<ResT> = ZodType<ResT>,
>(url: string, schema: Schema): AsyncResult<Infer<Schema>, SafeJsonError>;
getYamlSafe<
ResT extends NonNullable<unknown>,
Schema extends ZodType<ResT> = ZodType<ResT>,
>(
url: string,
options: Opts,
schema: Schema,
): AsyncResult<Infer<Schema>, SafeJsonError>;
getYamlSafe<
ResT extends NonNullable<unknown>,
Schema extends ZodType<ResT> = ZodType<ResT>,
>(
arg1: string,
arg2: Opts | Schema,
arg3?: Schema,
): AsyncResult<ResT, SafeJsonError> {
const url = arg1;
let schema: Schema;
let httpOptions: Opts | undefined;
if (arg3) {
schema = arg3;
httpOptions = arg2 as Opts;
} else {
schema = arg2 as Schema;
}
let res: AsyncResult<HttpResponse<ResT>, SafeJsonError>;
if (httpOptions) {
res = Result.wrap(this.getYaml(url, httpOptions, schema));
} else {
res = Result.wrap(this.getYaml(url, schema));
}
return res.transform((response) => Result.ok(response.body));
}
/**
* Request JSON and return the response without any validation.
*
* The usage of this method is discouraged, please use `getJson` instead.
*
* If you're new to Zod schema validation library:
* - consult the [documentation of Zod library](https://github.com/colinhacks/zod?tab=readme-ov-file#basic-usage)
* - search the Renovate codebase for 'zod' module usage
* - take a look at the `schema-utils.ts` file for Renovate-specific schemas and utilities
*/
getJsonUnchecked<ResT = unknown>(
url: string,
options?: JSONOpts,
): Promise<HttpResponse<ResT>> {
return this.requestJson<ResT>('get', { url, httpOptions: options });
}
/**
* Request JSON with a Zod schema for the response,
* throwing an error if the response is not valid.
*
* @param url
* @param schema Zod schema for the response
*/
getJson<Schema extends ZodType<any, any, any>>(
url: string,
schema: Schema,
): Promise<HttpResponse<Infer<Schema>>>;
getJson<Schema extends ZodType<any, any, any>>(
url: string,
options: JSONOpts,
schema: Schema,
): Promise<HttpResponse<Infer<Schema>>>;
getJson<Schema extends ZodType<any, any, any>>(
arg1: string,
arg2?: JSONOpts | Schema,
arg3?: Schema,
): Promise<HttpResponse<Infer<Schema>>> {
const args = this.resolveArgs<Infer<Schema>>(arg1, arg2, arg3);
return this.requestJson<Infer<Schema>>('get', args);
}
/**
* Request JSON with a Zod schema for the response,
* wrapping response data in a `Result` class.
*
* @param url
* @param schema Zod schema for the response
*/
getJsonSafe<ResT extends NonNullable<unknown>, Schema extends ZodType<ResT>>(
url: string,
schema: Schema,
): AsyncResult<Infer<Schema>, SafeJsonError>;
getJsonSafe<ResT extends NonNullable<unknown>, Schema extends ZodType<ResT>>(
url: string,
options: JSONOpts,
schema: Schema,
): AsyncResult<Infer<Schema>, SafeJsonError>;
getJsonSafe<ResT extends NonNullable<unknown>, Schema extends ZodType<ResT>>(
arg1: string,
arg2?: JSONOpts | Schema,
arg3?: Schema,
): AsyncResult<ResT, SafeJsonError> {
const args = this.resolveArgs<ResT>(arg1, arg2, arg3);
return Result.wrap(this.requestJson<ResT>('get', args)).transform(
(response) => Result.ok(response.body),
);
}
/**
* @deprecated use `head` instead
*/
headJson(url: string, httpOptions?: JSONOpts): Promise<HttpResponse<never>> {
return this.requestJson<never>('head', { url, httpOptions });
}
postJson<T>(url: string, options?: JSONOpts): Promise<HttpResponse<T>>;
postJson<T, Schema extends ZodType<T> = ZodType<T>>(
url: string,
schema: Schema,
): Promise<HttpResponse<Infer<Schema>>>;
postJson<T, Schema extends ZodType<T> = ZodType<T>>(
url: string,
options: JSONOpts,
schema: Schema,
): Promise<HttpResponse<Infer<Schema>>>;
postJson<T = unknown, Schema extends ZodType<T> = ZodType<T>>(
arg1: string,
arg2?: JSONOpts | Schema,
arg3?: Schema,
): Promise<HttpResponse<T>> {
const args = this.resolveArgs(arg1, arg2, arg3);
return this.requestJson<T>('post', args);
}
putJson<T>(url: string, options?: JSONOpts): Promise<HttpResponse<T>>;
putJson<T, Schema extends ZodType<T> = ZodType<T>>(
url: string,
schema: Schema,
): Promise<HttpResponse<Infer<Schema>>>;
putJson<T, Schema extends ZodType<T> = ZodType<T>>(
url: string,
options: JSONOpts,
schema: Schema,
): Promise<HttpResponse<Infer<Schema>>>;
putJson<T = unknown, Schema extends ZodType<T> = ZodType<T>>(
arg1: string,
arg2?: JSONOpts | Schema,
arg3?: ZodType,
): Promise<HttpResponse<T>> {
const args = this.resolveArgs(arg1, arg2, arg3);
return this.requestJson<T>('put', args);
}
patchJson<T>(url: string, options?: JSONOpts): Promise<HttpResponse<T>>;
patchJson<T, Schema extends ZodType<T> = ZodType<T>>(
url: string,
schema: Schema,
): Promise<HttpResponse<Infer<Schema>>>;
patchJson<T, Schema extends ZodType<T> = ZodType<T>>(
url: string,
options: JSONOpts,
schema: Schema,
): Promise<HttpResponse<Infer<Schema>>>;
patchJson<T = unknown, Schema extends ZodType<T> = ZodType<T>>(
arg1: string,
arg2?: JSONOpts | Schema,
arg3?: Schema,
): Promise<HttpResponse<T>> {
const args = this.resolveArgs(arg1, arg2, arg3);
return this.requestJson<T>('patch', args);
}
deleteJson<T>(url: string, options?: JSONOpts): Promise<HttpResponse<T>>;
deleteJson<T, Schema extends ZodType<T> = ZodType<T>>(
url: string,
schema: Schema,
): Promise<HttpResponse<Infer<Schema>>>;
deleteJson<T, Schema extends ZodType<T> = ZodType<T>>(
url: string,
options: JSONOpts,
schema: Schema,
): Promise<HttpResponse<Infer<Schema>>>;
deleteJson<T = unknown, Schema extends ZodType<T> = ZodType<T>>(
arg1: string,
arg2?: JSONOpts | Schema,
arg3?: Schema,
): Promise<HttpResponse<T>> {
const args = this.resolveArgs(arg1, arg2, arg3);
return this.requestJson<T>('delete', args);
}
stream(url: string, options?: HttpOptions): NodeJS.ReadableStream {
let combinedOptions: Merge<
GotStreamOptions,
SetRequired<InternalHttpOptions, 'method'>
> = {
...this.options,
hostType: this.hostType,
...options,
method: 'get',
};
const resolvedUrl = this.resolveUrl(url, options).toString();
applyDefaultHeaders(combinedOptions);
if (
is.undefined(combinedOptions.readOnly) &&
['head', 'get'].includes(combinedOptions.method)
) {
combinedOptions.readOnly = true;
}
const hostRule = findMatchingRule(url, combinedOptions);
combinedOptions = applyHostRule(resolvedUrl, combinedOptions, hostRule);
if (combinedOptions.enabled === false) {
throw new Error(HOST_DISABLED);
}
combinedOptions = applyAuthorization(combinedOptions);
return stream(resolvedUrl, combinedOptions);
}
}