diff --git a/lib/util/promises.spec.ts b/lib/util/promises.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..b0c575dab9715adb0ba1d7ff55f25e8ba6104bf2 --- /dev/null +++ b/lib/util/promises.spec.ts @@ -0,0 +1,81 @@ +import { ExternalHostError } from '../types/errors/external-host-error'; +import * as p from './promises'; + +describe('util/promises', () => { + describe('all', () => { + it('works', async () => { + const queue = p.all([ + () => Promise.resolve(1), + () => Promise.resolve(2), + () => Promise.resolve(3), + ]); + await expect(queue).resolves.toEqual([1, 2, 3]); + }); + }); + + describe('map', () => { + it('works', async () => { + const queue = p.map([1, 2, 3], (x) => Promise.resolve(x + 1)); + await expect(queue).resolves.toEqual([2, 3, 4]); + }); + }); + + describe('Error handling', () => { + it('throws first ExternalHostError found', async () => { + const unknownErr = new Error('fail'); + const hostErr = new ExternalHostError(unknownErr); + let res: Error | string[] | null = null; + try { + res = await p.all([ + () => Promise.resolve('ok'), + () => Promise.reject(unknownErr), + () => Promise.reject(hostErr), + ]); + } catch (err) { + res = err; + } + expect(res).toBe(hostErr); + }); + + it('throws first error if error messages are all the same', async () => { + const err1 = new Error('some error'); + const err2 = new Error('some error'); + const err3 = new Error('some error'); + let res: Error | string[] | null = null; + try { + res = await p.all([ + () => Promise.reject(err1), + () => Promise.reject(err2), + () => Promise.reject(err3), + ]); + } catch (err) { + res = err; + } + expect(res).toBe(err1); + }); + + it('throws aggregate error for different error messages', async () => { + await expect( + p.map([1, 2, 3], (x) => Promise.reject(new Error(`error ${x}`))) + ).rejects.toHaveProperty('name', 'AggregateError'); + }); + + it('re-throws when stopOnError=true', async () => { + const unknownErr = new Error('fail'); + let res: Error | string[] | null = null; + try { + res = await p.all( + [ + () => Promise.resolve('ok'), + () => Promise.resolve('ok'), + () => Promise.reject(unknownErr), + ], + { stopOnError: true } + ); + } catch (err) { + res = err; + } + expect(res).toBe(unknownErr); + }); + }); +}); diff --git a/lib/util/promises.ts b/lib/util/promises.ts index a0145ced65fe181efbc95b0b2d7fcbb0fea67e43..259369100a122a9ea138b54437653df961b18164 100644 --- a/lib/util/promises.ts +++ b/lib/util/promises.ts @@ -1,27 +1,69 @@ +import AggregateError from 'aggregate-error'; import pAll from 'p-all'; import pMap from 'p-map'; +import { logger } from '../logger'; +import { ExternalHostError } from '../types/errors/external-host-error'; type PromiseFactory<T> = () => Promise<T>; -export function all<T>( +function isExternalHostError(err: any): err is ExternalHostError { + return err instanceof ExternalHostError; +} + +function handleError(err: any): never { + if (!(err instanceof AggregateError)) { + throw err; + } + + logger.debug({ err }, 'Aggregate error is thrown'); + + const errors = [...err]; + + const hostError = errors.find(isExternalHostError); + if (hostError) { + throw hostError; + } + + if ( + errors.length === 1 || + new Set(errors.map(({ message }) => message)).size === 1 + ) { + const [error] = errors; + throw error; + } + + throw err; +} + +export async function all<T>( tasks: PromiseFactory<T>[], options?: pAll.Options ): Promise<T[]> { - return pAll(tasks, { - concurrency: 5, - ...options, - stopOnError: true, - }); + try { + const res = await pAll(tasks, { + concurrency: 5, + stopOnError: false, + ...options, + }); + return res; + } catch (err) { + return handleError(err); + } } -export function map<Element, NewElement>( +export async function map<Element, NewElement>( input: Iterable<Element>, mapper: pMap.Mapper<Element, NewElement>, options?: pMap.Options ): Promise<NewElement[]> { - return pMap(input, mapper, { - concurrency: 5, - ...options, - stopOnError: true, - }); + try { + const res = await pMap(input, mapper, { + concurrency: 5, + stopOnError: false, + ...options, + }); + return res; + } catch (err) { + return handleError(err); + } } diff --git a/package.json b/package.json index a955be6f183b961a08384e3d3da0eb1d82d74a3f..5382f5cd76a65a314394e150418a02a4c88907eb 100644 --- a/package.json +++ b/package.json @@ -150,6 +150,7 @@ "@yarnpkg/core": "3.2.4", "@yarnpkg/parsers": "2.5.1", "agentkeepalive": "4.2.1", + "aggregate-error": "3.1.0", "auth-header": "1.0.0", "azure-devops-node-api": "11.2.0", "bunyan": "1.8.15", diff --git a/yarn.lock b/yarn.lock index 895485dde83c3ae58e3833c6676845eaa7ab9eff..711b3ffbc71d5b966b32c5107c32957b47521a72 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3181,7 +3181,7 @@ agentkeepalive@4.2.1, agentkeepalive@^4.2.1: depd "^1.1.2" humanize-ms "^1.2.1" -aggregate-error@^3.0.0: +aggregate-error@3.1.0, aggregate-error@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/aggregate-error/-/aggregate-error-3.1.0.tgz#92670ff50f5359bdb7a3e0d40d0ec30c5737687a" integrity sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==