diff --git a/docs/usage/configuration-options.md b/docs/usage/configuration-options.md index 309b4a2217dbef..236305f98eb85d 100644 --- a/docs/usage/configuration-options.md +++ b/docs/usage/configuration-options.md @@ -445,6 +445,67 @@ Example for configuring `docker` auth: } ``` +### abortIgnoreStatusCodes + +This field can be used to configure status codes that Renovate ignores and passes through when `abortOnError` is set to `true`. For example to also skip 404 responses then configure the following: + +```json +{ + "hostRules": [ + { + "abortOnError": true, + "abortStatusCodes": [404] + } + ] +} +``` + +Note that this field is _not_ mergeable, so the last-applied host rule will take precedence. + +### abortOnError + +Use this field to configure Renovate to abort runs for custom hosts. By default, Renovate will only abort for known public hosts, which has the downside that transient errors for other hosts can cause autoclosing of PRs. + +To abort Renovate runs for http failures from _any_ host: + +```json +{ + "hostRules": [ + { + "abortOnError": true + } + ] +} +``` + +To abort Renovate runs for any `docker` datasource failures: + +```json +{ + "hostRules": [ + { + "hostType": "docker", + "abortOnError": true + } + ] +} +``` + +To abort Renovate for errors for a specific `docker` host: + +```json +{ + "hostRules": [ + { + "hostName": "docker.company.com", + "abortOnError": true + } + ] +} +``` + +When this field is enabled, Renovate will abort its run if it encounters either (a) any low-level http error (e.g. `ETIMEDOUT`) or (b) receives a response _not_ matching any of the configured `abortIgnoreStatusCodes` (e.g. `500 Internal Error`); + ### baseUrl Use this instead of `domainName` or `hostName` if you need a rule to apply to a specific path on a host. For example, `"baseUrl": "https://api.github.com"` is equivalent to `"hostName": "api.github.com"` but `"baseUrl": "https://api.github.com/google/"` is not. diff --git a/lib/config/definitions.ts b/lib/config/definitions.ts index df4622ca1803e8..ca7e6d77a4848e 100644 --- a/lib/config/definitions.ts +++ b/lib/config/definitions.ts @@ -38,12 +38,13 @@ export interface RenovateOptionBase { stage?: RenovateConfigStage; } -export interface RenovateArrayOption - extends RenovateOptionBase { +export interface RenovateArrayOption< + T extends string | number | object = object +> extends RenovateOptionBase { default?: T[]; mergeable?: boolean; type: 'array'; - subType?: 'string' | 'object'; + subType?: 'string' | 'object' | 'number'; } export interface RenovateStringArrayOption extends RenovateArrayOption { @@ -51,6 +52,10 @@ export interface RenovateStringArrayOption extends RenovateArrayOption { subType: 'string'; } +export interface RenovateNumberArrayOption extends RenovateArrayOption { + subType: 'number'; +} + export interface RenovateBooleanOption extends RenovateOptionBase { default?: boolean; type: 'boolean'; @@ -79,6 +84,7 @@ export interface RenovateObjectOption extends RenovateOptionBase { export type RenovateOptions = | RenovateStringOption + | RenovateNumberArrayOption | RenovateStringArrayOption | RenovateIntegerOption | RenovateBooleanOption @@ -1613,6 +1619,28 @@ const options: RenovateOptions[] = [ cli: false, env: false, }, + { + name: 'abortOnError', + description: + 'If enabled, Renovate will abort its run when http request errors occur.', + type: 'boolean', + stage: 'repository', + parent: 'hostRules', + default: false, + cli: false, + env: false, + }, + { + name: 'abortIgnoreStatusCodes', + description: + 'A list of HTTP status codes to ignore and *not* abort the run because of when abortOnError=true.', + type: 'array', + subType: 'number', + stage: 'repository', + parent: 'hostRules', + cli: false, + env: false, + }, { name: 'prBodyDefinitions', description: 'Table column definitions for use in PR tables', diff --git a/lib/types/host-rules.ts b/lib/types/host-rules.ts index 96a6a8477d10c0..403b8dad9730c1 100644 --- a/lib/types/host-rules.ts +++ b/lib/types/host-rules.ts @@ -13,4 +13,6 @@ export interface HostRule { platform?: string; timeout?: number; encrypted?: HostRule; + abortOnError?: boolean; + abortIgnoreStatusCodes?: number[]; } diff --git a/lib/util/http/host-rules.ts b/lib/util/http/host-rules.ts index 57c6f4e201d920..6d1f7229893a1d 100644 --- a/lib/util/http/host-rules.ts +++ b/lib/util/http/host-rules.ts @@ -10,7 +10,7 @@ export function applyHostRules(url: string, inOptions: any): any { hostType: options.hostType, url, }) || {}; - const { username, password, token, timeout } = foundRules; + const { username, password, token } = foundRules; if (options.headers?.authorization || options.auth || options.token) { logger.trace('Authorization already set for host: ' + options.hostname); } else if (password) { @@ -20,8 +20,11 @@ export function applyHostRules(url: string, inOptions: any): any { logger.trace('Applying Bearer authentication for host ' + options.hostname); options.token = token; } - if (timeout) { - options.timeout = timeout; - } + // Apply optional params + ['abortOnError', 'abortIgnoreStatusCodes', 'timeout'].forEach((param) => { + if (foundRules[param]) { + options[param] = foundRules[param]; + } + }); return options; } diff --git a/lib/util/http/index.spec.ts b/lib/util/http/index.spec.ts index 78dfc1938c75f2..f97e240dc77c21 100644 --- a/lib/util/http/index.spec.ts +++ b/lib/util/http/index.spec.ts @@ -1,5 +1,7 @@ import nock from 'nock'; import { getName } from '../../../test/util'; +import { EXTERNAL_HOST_ERROR } from '../../constants/error-messages'; +import * as hostRules from '../host-rules'; import { Http } from '.'; const baseUrl = 'http://renovate.com'; @@ -10,12 +12,36 @@ describe(getName(__filename), () => { beforeEach(() => { http = new Http('dummy'); nock.cleanAll(); + hostRules.clear(); }); it('get', async () => { nock(baseUrl).get('/test').reply(200); expect(await http.get('http://renovate.com/test')).toMatchSnapshot(); expect(nock.isDone()).toBe(true); }); + it('returns 429 error', async () => { + nock(baseUrl).get('/test').reply(429); + await expect(http.get('http://renovate.com/test')).rejects.toThrow( + 'Response code 429 (Too Many Requests)' + ); + expect(nock.isDone()).toBe(true); + }); + it('converts 404 error to ExternalHostError', async () => { + nock(baseUrl).get('/test').reply(404); + hostRules.add({ abortOnError: true }); + await expect(http.get('http://renovate.com/test')).rejects.toThrow( + EXTERNAL_HOST_ERROR + ); + expect(nock.isDone()).toBe(true); + }); + it('ignores 404 error and does not throw ExternalHostError', async () => { + nock(baseUrl).get('/test').reply(404); + hostRules.add({ abortOnError: true, abortIgnoreStatusCodes: [404] }); + await expect(http.get('http://renovate.com/test')).rejects.toThrow( + 'Response code 404 (Not Found)' + ); + expect(nock.isDone()).toBe(true); + }); it('getJson', async () => { nock(baseUrl).get('/').reply(200, '{ "test": true }'); expect(await http.getJson('http://renovate.com')).toMatchSnapshot(); diff --git a/lib/util/http/index.ts b/lib/util/http/index.ts index 5b456e3bee2bff..bb42c5b888d677 100644 --- a/lib/util/http/index.ts +++ b/lib/util/http/index.ts @@ -1,6 +1,7 @@ import crypto from 'crypto'; import URL from 'url'; import got from 'got'; +import { ExternalHostError } from '../../types/error'; import * as runCache from '../cache/run'; import { clone } from '../clone'; import { applyAuthorization } from './auth'; @@ -41,6 +42,21 @@ function cloneResponse(response: any): HttpResponse { }; } +async function resolveResponse( + promisedRes: Promise>, + { abortOnError, abortIgnoreStatusCodes } +): Promise> { + try { + const res = await promisedRes; + return cloneResponse(res); + } catch (err) { + if (abortOnError && !abortIgnoreStatusCodes?.includes(err.statusCode)) { + throw new ExternalHostError(err); + } + throw err; + } +} + export class Http { constructor(private hostType: string, private options?: HttpOptions) {} @@ -102,7 +118,7 @@ export class Http { const cachedRes = runCache.get(cacheKey); // istanbul ignore if if (cachedRes) { - return cloneResponse(await cachedRes); + return resolveResponse(cachedRes, options); } } const startTime = Date.now(); @@ -110,7 +126,7 @@ export class Http { if (options.method === 'get') { runCache.set(cacheKey, promisedRes); // always set if it's a get } - const res = await promisedRes; + const res = await resolveResponse(promisedRes, options); const httpRequests = runCache.get('http-requests') || []; httpRequests.push({ method: options.method, @@ -118,7 +134,7 @@ export class Http { duration: Date.now() - startTime, }); runCache.set('http-requests', httpRequests); - return cloneResponse(res); + return res; } get(url: string, options: HttpOptions = {}): Promise {