diff --git a/changelog.txt b/changelog.txt index e69de29bb..404ffb503 100644 --- a/changelog.txt +++ b/changelog.txt @@ -0,0 +1 @@ +feature - Allow specifying retry policies for event triggered functions. diff --git a/spec/function-builder.spec.ts b/spec/function-builder.spec.ts index b0c4d4c28..1ba12c7fa 100644 --- a/spec/function-builder.spec.ts +++ b/spec/function-builder.spec.ts @@ -79,14 +79,29 @@ describe('FunctionBuilder', () => { it('should allow valid runtime options to be set', () => { const fn = functions .runWith({ - timeoutSeconds: 90, + failurePolicy: { retry: {} }, memory: '256MB', + timeoutSeconds: 90, }) .auth.user() .onCreate((user) => user); expect(fn.__trigger.availableMemoryMb).to.deep.equal(256); expect(fn.__trigger.timeout).to.deep.equal('90s'); + expect(fn.__trigger.failurePolicy).to.deep.equal({ retry: {} }); + }); + + it("should apply a default failure policy if it's aliased with `true`", () => { + const fn = functions + .runWith({ + failurePolicy: true, + memory: '256MB', + timeoutSeconds: 90, + }) + .auth.user() + .onCreate((user) => user); + + expect(fn.__trigger.failurePolicy).to.deep.equal({ retry: {} }); }); it('should allow both supported region and valid runtime options to be set', () => { @@ -132,7 +147,26 @@ describe('FunctionBuilder', () => { functions .region('asia-northeast1') .runWith({ timeoutSeconds: 600, memory: '256MB' }); - }).to.throw(Error, 'TimeoutSeconds'); + }).to.throw(Error, 'RuntimeOptions.timeoutSeconds'); + }); + + it('should throw an error if user chooses a failurePolicy which is neither an object nor a boolean', () => { + expect(() => + functions.runWith({ + failurePolicy: (1234 as unknown) as functions.RuntimeOptions['failurePolicy'], + }) + ).to.throw( + Error, + 'RuntimeOptions.failurePolicy must be a boolean or an object' + ); + }); + + it('should throw an error if user chooses a failurePolicy.retry which is not an object', () => { + expect(() => + functions.runWith({ + failurePolicy: { retry: (1234 as unknown) as object }, + }) + ).to.throw(Error, 'RuntimeOptions.failurePolicy.retry'); }); it('should throw an error if user chooses an invalid memory allocation', () => { @@ -154,13 +188,13 @@ describe('FunctionBuilder', () => { return functions.runWith({ timeoutSeconds: 1000000, } as any); - }).to.throw(Error, 'TimeoutSeconds'); + }).to.throw(Error, 'RuntimeOptions.timeoutSeconds'); expect(() => { return functions.region('asia-east2').runWith({ timeoutSeconds: 1000000, } as any); - }).to.throw(Error, 'TimeoutSeconds'); + }).to.throw(Error, 'RuntimeOptions.timeoutSeconds'); }); it('should throw an error if user chooses an invalid region', () => { diff --git a/spec/providers/auth.spec.ts b/spec/providers/auth.spec.ts index 769bc03bf..bb2ab7761 100644 --- a/spec/providers/auth.spec.ts +++ b/spec/providers/auth.spec.ts @@ -197,9 +197,7 @@ describe('Auth Functions', () => { }); describe('#onDelete', () => { - const cloudFunctionDelete: CloudFunction< - firebase.auth.UserRecord - > = functions.handler.auth.user.onDelete( + const cloudFunctionDelete: CloudFunction = functions.handler.auth.user.onDelete( (data: firebase.auth.UserRecord) => data ); diff --git a/src/cloud-functions.ts b/src/cloud-functions.ts index 1309aba61..b18c965d7 100644 --- a/src/cloud-functions.ts +++ b/src/cloud-functions.ts @@ -22,7 +22,13 @@ import { Request, Response } from 'express'; import * as _ from 'lodash'; -import { DeploymentOptions, Schedule } from './function-configuration'; +import { + DEFAULT_FAILURE_POLICY, + DeploymentOptions, + FailurePolicy, + MEMORY_LOOKUP, + Schedule, +} from './function-configuration'; export { Request, Response }; /** @hidden */ @@ -202,6 +208,7 @@ export namespace Change { if (json.fieldMask) { before = applyFieldMask(before, json.after, json.fieldMask); } + return Change.fromObjects( customizer(before || {}), customizer(json.after || {}) @@ -216,7 +223,8 @@ export namespace Change { ) { const before = _.assign({}, after); const masks = fieldMask.split(','); - _.forEach(masks, (mask) => { + + masks.forEach((mask) => { const val = _.get(sparseBefore, mask); if (typeof val === 'undefined') { _.unset(before, mask); @@ -224,6 +232,7 @@ export namespace Change { _.set(before, mask, val); } }); + return before; } } @@ -253,6 +262,7 @@ export interface TriggerAnnotated { resource: string; service: string; }; + failurePolicy?: FailurePolicy; httpsTrigger?: {}; labels?: { [key: string]: string }; regions?: string[]; @@ -312,6 +322,40 @@ export interface MakeCloudFunctionArgs { triggerResource: () => string; } +/** @hidden */ +export function optionsToTrigger({ + failurePolicy: failurePolicyOrAlias, + memory, + regions, + schedule, + timeoutSeconds, +}: DeploymentOptions): TriggerAnnotated['__trigger'] { + /* + * FailurePolicy can be aliased with a boolean value in the public API. + * Convert aliases `true` and `false` to a standardized interface. + */ + const failurePolicy: FailurePolicy | undefined = + failurePolicyOrAlias === false + ? undefined + : failurePolicyOrAlias === true + ? DEFAULT_FAILURE_POLICY + : failurePolicyOrAlias; + + const availableMemoryMb: number | undefined = + memory === undefined ? undefined : MEMORY_LOOKUP[memory]; + + const timeout: string | undefined = + timeoutSeconds === undefined ? undefined : `${timeoutSeconds}s`; + + return { + ...(failurePolicy === undefined ? {} : { failurePolicy }), + ...(availableMemoryMb === undefined ? {} : { availableMemoryMb }), + ...(regions === undefined ? {} : { regions }), + ...(schedule === undefined ? {} : { schedule }), + ...(timeout === undefined ? {} : { timeout }), + }; +} + /** @hidden */ export function makeCloudFunction({ after = () => {}, @@ -463,28 +507,3 @@ function _detectAuthType(event: Event) { } return 'UNAUTHENTICATED'; } - -/** @hidden */ -export function optionsToTrigger(options: DeploymentOptions) { - const trigger: any = {}; - if (options.regions) { - trigger.regions = options.regions; - } - if (options.timeoutSeconds) { - trigger.timeout = options.timeoutSeconds.toString() + 's'; - } - if (options.memory) { - const memoryLookup = { - '128MB': 128, - '256MB': 256, - '512MB': 512, - '1GB': 1024, - '2GB': 2048, - }; - trigger.availableMemoryMb = _.get(memoryLookup, options.memory); - } - if (options.schedule) { - trigger.schedule = options.schedule; - } - return trigger; -} diff --git a/src/function-builder.ts b/src/function-builder.ts index 580e0498b..13fd30990 100644 --- a/src/function-builder.ts +++ b/src/function-builder.ts @@ -27,6 +27,7 @@ import { CloudFunction, EventContext } from './cloud-functions'; import { DeploymentOptions, MAX_TIMEOUT_SECONDS, + MIN_TIMEOUT_SECONDS, RuntimeOptions, SUPPORTED_REGIONS, VALID_MEMORY_OPTIONS, @@ -45,28 +46,62 @@ import * as testLab from './providers/testLab'; /** * Assert that the runtime options passed in are valid. * @param runtimeOptions object containing memory and timeout information. - * @throws { Error } Memory and TimeoutSeconds values must be valid. + * @throws { Error } FailurePolicy, Memory and TimeoutSeconds values must be + * valid. */ -function assertRuntimeOptionsValid(runtimeOptions: RuntimeOptions): boolean { - if ( - runtimeOptions.memory && - !_.includes(VALID_MEMORY_OPTIONS, runtimeOptions.memory) - ) { - throw new Error( - `The only valid memory allocation values are: ${VALID_MEMORY_OPTIONS.join( - ', ' - )}` - ); +function assertRuntimeOptionsValidity(runtimeOptions: RuntimeOptions): void { + if (_.isObjectLike(runtimeOptions) === false) { + throw new Error('RuntimeOptions must be an object.'); } - if ( - runtimeOptions.timeoutSeconds > MAX_TIMEOUT_SECONDS || - runtimeOptions.timeoutSeconds < 0 - ) { - throw new Error( - `TimeoutSeconds must be between 0 and ${MAX_TIMEOUT_SECONDS}` - ); + + const { failurePolicy, memory, timeoutSeconds } = runtimeOptions; + + if (failurePolicy !== undefined) { + if ( + _.isBoolean(failurePolicy) === false && + _.isObjectLike(failurePolicy) === false + ) { + throw new Error( + `RuntimeOptions.failurePolicy must be a boolean or an object.` + ); + } + + if (typeof failurePolicy === 'object') { + if ( + _.isObjectLike(failurePolicy.retry) === false || + _.isEmpty(failurePolicy.retry) === false + ) { + throw new Error( + 'RuntimeOptions.failurePolicy.retry must be an empty object.' + ); + } + } + } + + if (memory !== undefined) { + if (_.includes(VALID_MEMORY_OPTIONS, memory) === false) { + throw new Error( + `RuntimeOptions.memory must be one of: ${VALID_MEMORY_OPTIONS.join( + ', ' + )}.` + ); + } + } + + if (timeoutSeconds !== undefined) { + if (typeof timeoutSeconds !== 'number') { + throw new Error('RuntimeOptions.timeoutSeconds must be a number.'); + } + + if ( + timeoutSeconds < MIN_TIMEOUT_SECONDS || + timeoutSeconds > MAX_TIMEOUT_SECONDS + ) { + throw new Error( + `RuntimeOptions.timeoutSeconds must be between ${MIN_TIMEOUT_SECONDS} and ${MAX_TIMEOUT_SECONDS}.` + ); + } } - return true; } /** @@ -74,16 +109,16 @@ function assertRuntimeOptionsValid(runtimeOptions: RuntimeOptions): boolean { * @param regions list of regions. * @throws { Error } Regions must be in list of supported regions. */ -function assertRegionsAreValid(regions: string[]): boolean { - if (!regions.length) { - throw new Error('You must specify at least one region'); +function assertRegionsValidity(regions: string[]): void { + if (regions.length === 0) { + throw new Error('You must specify at least one region.'); } - if (_.difference(regions, SUPPORTED_REGIONS).length) { + + if (_.difference(regions, SUPPORTED_REGIONS).length !== 0) { throw new Error( - `The only valid regions are: ${SUPPORTED_REGIONS.join(', ')}` + `The only valid regions are: ${SUPPORTED_REGIONS.join(', ')}.` ); } - return true; } /** @@ -97,23 +132,27 @@ function assertRegionsAreValid(regions: string[]): boolean { export function region( ...regions: Array ): FunctionBuilder { - if (assertRegionsAreValid(regions)) { - return new FunctionBuilder({ regions }); - } + assertRegionsValidity(regions); + + return new FunctionBuilder({ regions }); } /** * Configure runtime options for the function. * @param runtimeOptions Object with three optional fields: - * 1. memory: amount of memory to allocate to the function, possible values - * are: '128MB', '256MB', '512MB', '1GB', and '2GB'. - * 2. timeoutSeconds: timeout for the function in seconds, possible values are - * 0 to 540. + * 1. failurePolicy: failure policy of the function, with boolean `true` being + * equivalent to providing an empty retry object. + * 2. memory: amount of memory to allocate to the function, with possible + * values being '128MB', '256MB', '512MB', '1GB', and '2GB'. + * 3. timeoutSeconds: timeout for the function in seconds, with possible + * values being 0 to 540. + * + * Value must not be null. */ export function runWith(runtimeOptions: RuntimeOptions): FunctionBuilder { - if (assertRuntimeOptionsValid(runtimeOptions)) { - return new FunctionBuilder(runtimeOptions); - } + assertRuntimeOptionsValidity(runtimeOptions); + + return new FunctionBuilder(runtimeOptions); } export class FunctionBuilder { @@ -128,28 +167,40 @@ export class FunctionBuilder { * functions.region('us-east1', 'us-central1') */ region(...regions: Array): FunctionBuilder { - if (assertRegionsAreValid(regions)) { - this.options.regions = regions; - return this; - } + assertRegionsValidity(regions); + + this.options.regions = regions; + + return this; } /** * Configure runtime options for the function. * @param runtimeOptions Object with three optional fields: - * 1. memory: amount of memory to allocate to the function, possible values - * are: '128MB', '256MB', '512MB', '1GB', and '2GB'. - * 2. timeoutSeconds: timeout for the function in seconds, possible values are - * 0 to 540. + * 1. failurePolicy: failure policy of the function, with boolean `true` being + * equivalent to providing an empty retry object. + * 2. memory: amount of memory to allocate to the function, with possible + * values being '128MB', '256MB', '512MB', '1GB', and '2GB'. + * 3. timeoutSeconds: timeout for the function in seconds, with possible + * values being 0 to 540. + * + * Value must not be null. */ runWith(runtimeOptions: RuntimeOptions): FunctionBuilder { - if (assertRuntimeOptionsValid(runtimeOptions)) { - this.options = _.assign(this.options, runtimeOptions); - return this; - } + assertRuntimeOptionsValidity(runtimeOptions); + + this.options = _.assign(this.options, runtimeOptions); + + return this; } get https() { + if (this.options.failurePolicy !== undefined) { + console.warn( + 'RuntimeOptions.failurePolicy is not supported in https functions.' + ); + } + return { /** * Handle HTTP requests. @@ -161,7 +212,8 @@ export class FunctionBuilder { ) => https._onRequestWithOptions(handler, this.options), /** * Declares a callable method for clients to call using a Firebase SDK. - * @param handler A method that takes a data and context and returns a value. + * @param handler A method that takes a data and context and returns + * a value. */ onCall: ( handler: ( diff --git a/src/function-configuration.ts b/src/function-configuration.ts index ec1695ccd..f3883155b 100644 --- a/src/function-configuration.ts +++ b/src/function-configuration.ts @@ -32,6 +32,19 @@ export const VALID_MEMORY_OPTIONS = [ '2GB', ] as const; +/** + * A mapping of memory options to its representation in the Cloud Functions API. + */ +export const MEMORY_LOOKUP: { + [Name in typeof VALID_MEMORY_OPTIONS[number]]: number; +} = { + '128MB': 128, + '256MB': 256, + '512MB': 512, + '1GB': 1024, + '2GB': 2048, +}; + /** * Scheduler retry options. Applies only to scheduled functions. */ @@ -52,7 +65,20 @@ export interface Schedule { retryConfig?: ScheduleRetryConfig; } +export interface FailurePolicy { + retry: {}; +} + +export const DEFAULT_FAILURE_POLICY: FailurePolicy = { + retry: {}, +}; + export interface RuntimeOptions { + /** + * Failure policy of the function, with boolean `true` being equivalent to + * providing an empty retry object. + */ + failurePolicy?: FailurePolicy | boolean; /** * Amount of memory to allocate to the function. */