From 3b9696fe7d604aa95bb96ccc28b0e77519b74ce0 Mon Sep 17 00:00:00 2001 From: Eric Jizba Date: Wed, 21 Jun 2023 16:19:53 -0700 Subject: [PATCH 1/2] Add v4 support for specifying retry policy --- src/app.ts | 26 ++++++++++++++++--- src/converters/toRpcNullable.ts | 33 ++++++++++++++++------- types-core/index.d.ts | 27 +++++++++++++++++++ types/app.d.ts | 4 +-- types/cosmosDB.v3.d.ts | 8 +++++- types/cosmosDB.v4.d.ts | 8 +++++- types/eventHub.d.ts | 8 +++++- types/generic.d.ts | 10 +++++++ types/index.d.ts | 46 +++++++++++++++++++++++++++++++++ types/timer.d.ts | 8 +++++- 10 files changed, 159 insertions(+), 19 deletions(-) diff --git a/src/app.ts b/src/app.ts index 48728da..36d6eb7 100644 --- a/src/app.ts +++ b/src/app.ts @@ -6,7 +6,9 @@ import { CosmosDBTrigger, EventGridFunctionOptions, EventHubFunctionOptions, - FunctionOptions, + ExponentialBackoffRetryOptions, + FixedDelayRetryOptions, + GenericFunctionOptions, HttpFunctionOptions, HttpHandler, HttpMethod, @@ -21,6 +23,7 @@ import * as coreTypes from '@azure/functions-core'; import { CoreInvocationContext, FunctionCallback } from '@azure/functions-core'; import { InvocationModel } from './InvocationModel'; import { returnBindingKey, version } from './constants'; +import { toRpcTimestamp } from './converters/toRpcNullable'; import * as output from './output'; import * as trigger from './trigger'; import { isTrigger } from './utils/isTrigger'; @@ -231,7 +234,7 @@ export function cosmosDB(name: string, options: CosmosDBFunctionOptions): void { }); } -export function generic(name: string, options: FunctionOptions): void { +export function generic(name: string, options: GenericFunctionOptions): void { if (!hasSetup) { setup(); } @@ -271,12 +274,29 @@ export function generic(name: string, options: FunctionOptions): void { } } + let retryOptions: coreTypes.RpcRetryOptions | undefined; + if (options.retry) { + retryOptions = { + ...options.retry, + retryStrategy: options.retry.strategy, + delayInterval: toRpcTimestamp((options.retry).delayInterval, 'retry.delayInterval'), + maximumInterval: toRpcTimestamp( + (options.retry).maximumInterval, + 'retry.maximumInterval' + ), + minimumInterval: toRpcTimestamp( + (options.retry).minimumInterval, + 'retry.minimumInterval' + ), + }; + } + const coreApi = tryGetCoreApiLazy(); if (!coreApi) { console.warn( `WARNING: Skipping call to register function "${name}" because the "@azure/functions" package is in test mode.` ); } else { - coreApi.registerFunction({ name, bindings }, options.handler); + coreApi.registerFunction({ name, bindings, retryOptions }, options.handler); } } diff --git a/src/converters/toRpcNullable.ts b/src/converters/toRpcNullable.ts index 56375e8..00d0221 100644 --- a/src/converters/toRpcNullable.ts +++ b/src/converters/toRpcNullable.ts @@ -1,7 +1,13 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. -import { RpcNullableBool, RpcNullableDouble, RpcNullableString, RpcNullableTimestamp } from '@azure/functions-core'; +import { + RpcNullableBool, + RpcNullableDouble, + RpcNullableString, + RpcNullableTimestamp, + RpcTimestamp, +} from '@azure/functions-core'; import { AzFuncSystemError } from '../errors'; import { isDefined } from '../utils/nonNull'; @@ -101,25 +107,32 @@ export function toNullableString(nullable: string | undefined, propertyName: str return undefined; } -/** - * Converts Date or number input to an 'INullableTimestamp' to be sent through the RPC layer. - * Input that is not a Date or number but is also not null or undefined logs a function app level warning. - * @param nullable Input to be converted to an INullableTimestamp if it is valid input - * @param propertyName The name of the property that the caller will assign the output to. Used for debugging. - */ export function toNullableTimestamp( dateTime: Date | number | undefined, propertyName: string ): RpcNullableTimestamp | undefined { + if (isDefined(dateTime)) { + return { + value: toRpcTimestamp(dateTime, propertyName), + }; + } + return undefined; +} + +/** + * Converts Date or number input to an 'RpcTimestamp' to be sent through the RPC layer. + * Input that is not a Date or number but is also not null or undefined logs a function app level warning. + * @param dateTime Input to be converted to an RpcTimestamp if it is valid input + * @param propertyName The name of the property that the caller will assign the output to. Used for debugging. + */ +export function toRpcTimestamp(dateTime: Date | number | undefined, propertyName: string): RpcTimestamp | undefined { if (isDefined(dateTime)) { try { const timeInMilliseconds = typeof dateTime === 'number' ? dateTime : dateTime.getTime(); if (timeInMilliseconds && timeInMilliseconds >= 0) { return { - value: { - seconds: Math.round(timeInMilliseconds / 1000), - }, + seconds: Math.round(timeInMilliseconds / 1000), }; } } catch { diff --git a/types-core/index.d.ts b/types-core/index.d.ts index 252433b..3c7f965 100644 --- a/types-core/index.d.ts +++ b/types-core/index.d.ts @@ -43,6 +43,11 @@ declare module '@azure/functions-core' { * A dictionary of binding name to binding info */ bindings: { [name: string]: RpcBindingInfo }; + + /** + * The retry policy options + */ + retryOptions?: RpcRetryOptions; } /** @@ -298,6 +303,8 @@ declare module '@azure/functions-core' { functionId?: string | null; managedDependencyEnabled?: boolean | null; + + retryOptions?: RpcRetryOptions | null; } interface RpcStatusResult { @@ -352,6 +359,20 @@ declare module '@azure/functions-core' { type RpcBindingDataType = 'undefined' | 'string' | 'binary' | 'stream'; + interface RpcRetryOptions { + maxRetryCount?: number | null; + + delayInterval?: RpcDuration | null; + + minimumInterval?: RpcDuration | null; + + maximumInterval?: RpcDuration | null; + + retryStrategy?: RpcRetryStrategy | null; + } + + type RpcRetryStrategy = 'exponentialBackoff' | 'fixedDelay'; + interface RpcTypedData { string?: string | null; @@ -508,6 +529,12 @@ declare module '@azure/functions-core' { nanos?: number | null; } + interface RpcDuration { + seconds?: number | Long | null; + + nanos?: number | null; + } + type RpcHttpCookieSameSite = 'none' | 'lax' | 'strict' | 'explicitNone'; // #endregion rpc types } diff --git a/types/app.d.ts b/types/app.d.ts index 41dfdba..64a6d9c 100644 --- a/types/app.d.ts +++ b/types/app.d.ts @@ -4,8 +4,8 @@ import { CosmosDBFunctionOptions } from './cosmosDB'; import { EventGridFunctionOptions } from './eventGrid'; import { EventHubFunctionOptions } from './eventHub'; +import { GenericFunctionOptions } from './generic'; import { HttpFunctionOptions, HttpHandler, HttpMethodFunctionOptions } from './http'; -import { FunctionOptions } from './index'; import { ServiceBusQueueFunctionOptions, ServiceBusTopicFunctionOptions } from './serviceBus'; import { StorageBlobFunctionOptions, StorageQueueFunctionOptions } from './storage'; import { TimerFunctionOptions } from './timer'; @@ -149,4 +149,4 @@ export function cosmosDB(name: string, options: CosmosDBFunctionOptions): void; * @param name The name of the function. The name must be unique within your app and will mostly be used for your own tracking purposes * @param options Configuration options describing the inputs, outputs, and handler for this function */ -export function generic(name: string, options: FunctionOptions): void; +export function generic(name: string, options: GenericFunctionOptions): void; diff --git a/types/cosmosDB.v3.d.ts b/types/cosmosDB.v3.d.ts index 3ec8a3a..24af91b 100644 --- a/types/cosmosDB.v3.d.ts +++ b/types/cosmosDB.v3.d.ts @@ -1,7 +1,7 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. -import { FunctionInput, FunctionOptions, FunctionOutput, FunctionResult, FunctionTrigger } from './index'; +import { FunctionInput, FunctionOptions, FunctionOutput, FunctionResult, FunctionTrigger, RetryOptions } from './index'; import { InvocationContext } from './InvocationContext'; export type CosmosDBv3Handler = (documents: unknown[], context: InvocationContext) => FunctionResult; @@ -10,6 +10,12 @@ export interface CosmosDBv3FunctionOptions extends CosmosDBv3TriggerOptions, Par handler: CosmosDBv3Handler; trigger?: CosmosDBv3Trigger; + + /** + * An optional retry policy to rerun a failed execution until either successful completion occurs or the maximum number of retries is reached. + * Learn more [here](https://learn.microsoft.com/azure/azure-functions/functions-bindings-error-pages) + */ + retry?: RetryOptions; } export interface CosmosDBv3InputOptions { diff --git a/types/cosmosDB.v4.d.ts b/types/cosmosDB.v4.d.ts index 9eaad40..f6d929a 100644 --- a/types/cosmosDB.v4.d.ts +++ b/types/cosmosDB.v4.d.ts @@ -1,7 +1,7 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. -import { FunctionInput, FunctionOptions, FunctionOutput, FunctionResult, FunctionTrigger } from './index'; +import { FunctionInput, FunctionOptions, FunctionOutput, FunctionResult, FunctionTrigger, RetryOptions } from './index'; import { InvocationContext } from './InvocationContext'; export type CosmosDBv4Handler = (documents: unknown[], context: InvocationContext) => FunctionResult; @@ -10,6 +10,12 @@ export interface CosmosDBv4FunctionOptions extends CosmosDBv4TriggerOptions, Par handler: CosmosDBv4Handler; trigger?: CosmosDBv4Trigger; + + /** + * An optional retry policy to rerun a failed execution until either successful completion occurs or the maximum number of retries is reached. + * Learn more [here](https://learn.microsoft.com/azure/azure-functions/functions-bindings-error-pages) + */ + retry?: RetryOptions; } export interface CosmosDBv4InputOptions { diff --git a/types/eventHub.d.ts b/types/eventHub.d.ts index c9c1e51..2e1ddcb 100644 --- a/types/eventHub.d.ts +++ b/types/eventHub.d.ts @@ -1,7 +1,7 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. -import { FunctionOptions, FunctionOutput, FunctionResult, FunctionTrigger } from './index'; +import { FunctionOptions, FunctionOutput, FunctionResult, FunctionTrigger, RetryOptions } from './index'; import { InvocationContext } from './InvocationContext'; export type EventHubHandler = (messages: unknown, context: InvocationContext) => FunctionResult; @@ -10,6 +10,12 @@ export interface EventHubFunctionOptions extends EventHubTriggerOptions, Partial handler: EventHubHandler; trigger?: EventHubTrigger; + + /** + * An optional retry policy to rerun a failed execution until either successful completion occurs or the maximum number of retries is reached. + * Learn more [here](https://learn.microsoft.com/azure/azure-functions/functions-bindings-error-pages) + */ + retry?: RetryOptions; } export interface EventHubTriggerOptions { diff --git a/types/generic.d.ts b/types/generic.d.ts index 6bc49d7..faa1b42 100644 --- a/types/generic.d.ts +++ b/types/generic.d.ts @@ -1,6 +1,16 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. +import { FunctionOptions, RetryOptions } from './index'; + +export interface GenericFunctionOptions extends FunctionOptions { + /** + * An optional retry policy to rerun a failed execution until either successful completion occurs or the maximum number of retries is reached. + * Learn more [here](https://learn.microsoft.com/azure/azure-functions/functions-bindings-error-pages) + */ + retry?: RetryOptions; +} + export interface GenericTriggerOptions extends Record { type: string; } diff --git a/types/index.d.ts b/types/index.d.ts index 647a2b5..db4931c 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -112,3 +112,49 @@ export interface FunctionOutput extends Record { */ name: string; } + +export type RetryOptions = FixedDelayRetryOptions | ExponentialBackoffRetryOptions; + +export interface FixedDelayRetryOptions { + /** + * A specified amount of time is allowed to elapse between each retry. + */ + strategy: 'fixedDelay'; + + /** + * The maximum number of retries allowed per function execution. -1 means to retry indefinitely. + */ + maxRetryCount: number; + + /** + * The delay that's used between retries. + * This can be a Node.js Date or Unix time in milliseconds. + */ + delayInterval: Date | number; +} + +export interface ExponentialBackoffRetryOptions { + /** + * The first retry waits for the minimum delay. On subsequent retries, time is added exponentially to + * the initial duration for each retry, until the maximum delay is reached. Exponential back-off adds + * some small randomization to delays to stagger retries in high-throughput scenarios. + */ + strategy: 'exponentialBackoff'; + + /** + * The maximum number of retries allowed per function execution. -1 means to retry indefinitely. + */ + maxRetryCount: number; + + /** + * The minimum retry delay. + * This can be a Node.js Date or Unix time in milliseconds. + */ + minimumInterval: Date | number; + + /** + * The maximum retry delay. + * This can be a Node.js Date or Unix time in milliseconds. + */ + maximumInterval: Date | number; +} diff --git a/types/timer.d.ts b/types/timer.d.ts index 009f7a7..aa2d4c0 100644 --- a/types/timer.d.ts +++ b/types/timer.d.ts @@ -1,7 +1,7 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. -import { FunctionOptions, FunctionResult, FunctionTrigger } from './index'; +import { FunctionOptions, FunctionResult, FunctionTrigger, RetryOptions } from './index'; import { InvocationContext } from './InvocationContext'; export type TimerHandler = (myTimer: Timer, context: InvocationContext) => FunctionResult; @@ -10,6 +10,12 @@ export interface TimerFunctionOptions extends TimerTriggerOptions, Partial Date: Tue, 15 Aug 2023 17:13:07 -0700 Subject: [PATCH 2/2] update duration --- src/app.ts | 8 ++-- src/converters/toRpcDuration.ts | 36 +++++++++++++++++ src/converters/toRpcNullable.ts | 33 +++++----------- test/converters/toRpcDuration.test.ts | 56 +++++++++++++++++++++++++++ types/index.d.ts | 19 ++++++--- 5 files changed, 119 insertions(+), 33 deletions(-) create mode 100644 src/converters/toRpcDuration.ts create mode 100644 test/converters/toRpcDuration.test.ts diff --git a/src/app.ts b/src/app.ts index 36d6eb7..721bd13 100644 --- a/src/app.ts +++ b/src/app.ts @@ -23,7 +23,7 @@ import * as coreTypes from '@azure/functions-core'; import { CoreInvocationContext, FunctionCallback } from '@azure/functions-core'; import { InvocationModel } from './InvocationModel'; import { returnBindingKey, version } from './constants'; -import { toRpcTimestamp } from './converters/toRpcNullable'; +import { toRpcDuration } from './converters/toRpcDuration'; import * as output from './output'; import * as trigger from './trigger'; import { isTrigger } from './utils/isTrigger'; @@ -279,12 +279,12 @@ export function generic(name: string, options: GenericFunctionOptions): void { retryOptions = { ...options.retry, retryStrategy: options.retry.strategy, - delayInterval: toRpcTimestamp((options.retry).delayInterval, 'retry.delayInterval'), - maximumInterval: toRpcTimestamp( + delayInterval: toRpcDuration((options.retry).delayInterval, 'retry.delayInterval'), + maximumInterval: toRpcDuration( (options.retry).maximumInterval, 'retry.maximumInterval' ), - minimumInterval: toRpcTimestamp( + minimumInterval: toRpcDuration( (options.retry).minimumInterval, 'retry.minimumInterval' ), diff --git a/src/converters/toRpcDuration.ts b/src/converters/toRpcDuration.ts new file mode 100644 index 0000000..7e03c1f --- /dev/null +++ b/src/converters/toRpcDuration.ts @@ -0,0 +1,36 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. + +import { RpcDuration } from '@azure/functions-core'; +import { Duration } from '../../types'; +import { AzFuncSystemError } from '../errors'; +import { isDefined } from '../utils/nonNull'; + +export function toRpcDuration(dateTime: Duration | number | undefined, propertyName: string): RpcDuration | undefined { + if (isDefined(dateTime)) { + try { + let timeInMilliseconds: number | undefined; + if (typeof dateTime === 'object') { + const minutes = (dateTime.minutes || 0) + (dateTime.hours || 0) * 60; + const seconds = (dateTime.seconds || 0) + minutes * 60; + timeInMilliseconds = (dateTime.milliseconds || 0) + seconds * 1000; + } else if (typeof dateTime === 'number') { + timeInMilliseconds = dateTime; + } + + if (isDefined(timeInMilliseconds) && timeInMilliseconds >= 0) { + return { + seconds: Math.round(timeInMilliseconds / 1000), + }; + } + } catch { + // fall through + } + + throw new AzFuncSystemError( + `A 'number' or 'Duration' object was expected instead of a '${typeof dateTime}'. Cannot parse value of '${propertyName}'.` + ); + } + + return undefined; +} diff --git a/src/converters/toRpcNullable.ts b/src/converters/toRpcNullable.ts index 00d0221..56375e8 100644 --- a/src/converters/toRpcNullable.ts +++ b/src/converters/toRpcNullable.ts @@ -1,13 +1,7 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. -import { - RpcNullableBool, - RpcNullableDouble, - RpcNullableString, - RpcNullableTimestamp, - RpcTimestamp, -} from '@azure/functions-core'; +import { RpcNullableBool, RpcNullableDouble, RpcNullableString, RpcNullableTimestamp } from '@azure/functions-core'; import { AzFuncSystemError } from '../errors'; import { isDefined } from '../utils/nonNull'; @@ -107,32 +101,25 @@ export function toNullableString(nullable: string | undefined, propertyName: str return undefined; } -export function toNullableTimestamp( - dateTime: Date | number | undefined, - propertyName: string -): RpcNullableTimestamp | undefined { - if (isDefined(dateTime)) { - return { - value: toRpcTimestamp(dateTime, propertyName), - }; - } - return undefined; -} - /** - * Converts Date or number input to an 'RpcTimestamp' to be sent through the RPC layer. + * Converts Date or number input to an 'INullableTimestamp' to be sent through the RPC layer. * Input that is not a Date or number but is also not null or undefined logs a function app level warning. - * @param dateTime Input to be converted to an RpcTimestamp if it is valid input + * @param nullable Input to be converted to an INullableTimestamp if it is valid input * @param propertyName The name of the property that the caller will assign the output to. Used for debugging. */ -export function toRpcTimestamp(dateTime: Date | number | undefined, propertyName: string): RpcTimestamp | undefined { +export function toNullableTimestamp( + dateTime: Date | number | undefined, + propertyName: string +): RpcNullableTimestamp | undefined { if (isDefined(dateTime)) { try { const timeInMilliseconds = typeof dateTime === 'number' ? dateTime : dateTime.getTime(); if (timeInMilliseconds && timeInMilliseconds >= 0) { return { - seconds: Math.round(timeInMilliseconds / 1000), + value: { + seconds: Math.round(timeInMilliseconds / 1000), + }, }; } } catch { diff --git a/test/converters/toRpcDuration.test.ts b/test/converters/toRpcDuration.test.ts new file mode 100644 index 0000000..0f40136 --- /dev/null +++ b/test/converters/toRpcDuration.test.ts @@ -0,0 +1,56 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. + +import { expect } from 'chai'; +import 'mocha'; +import { toRpcDuration } from '../../src/converters/toRpcDuration'; + +describe('toRpcDuration', () => { + it('number', () => { + const value = toRpcDuration(5000, 'test'); + expect(value).to.deep.equal({ seconds: 5 }); + }); + + it('zero', () => { + const value = toRpcDuration(0, 'test'); + expect(value).to.deep.equal({ seconds: 0 }); + }); + + it('milliseconds', () => { + const value = toRpcDuration({ milliseconds: 6000 }, 'test'); + expect(value).to.deep.equal({ seconds: 6 }); + }); + + it('seconds', () => { + const value = toRpcDuration({ seconds: 7 }, 'test'); + expect(value).to.deep.equal({ seconds: 7 }); + }); + + it('minutes', () => { + const value = toRpcDuration({ minutes: 3 }, 'test'); + expect(value).to.deep.equal({ seconds: 180 }); + }); + + it('hours', () => { + const value = toRpcDuration({ hours: 2 }, 'test'); + expect(value).to.deep.equal({ seconds: 7200 }); + }); + + it('combined', () => { + const value = toRpcDuration({ hours: 1, minutes: 1, seconds: 1, milliseconds: 1000 }, 'test'); + expect(value).to.deep.equal({ seconds: 3662 }); + }); + + it('throws and does not convert string', () => { + expect(() => { + toRpcDuration('invalid', 'test'); + }).to.throw( + "A 'number' or 'Duration' object was expected instead of a 'string'. Cannot parse value of 'test'." + ); + }); + + it('does not convert null', () => { + const value = toRpcDuration(null, 'test'); + expect(value).to.be.undefined; + }); +}); diff --git a/types/index.d.ts b/types/index.d.ts index db4931c..43e8c49 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -128,9 +128,9 @@ export interface FixedDelayRetryOptions { /** * The delay that's used between retries. - * This can be a Node.js Date or Unix time in milliseconds. + * This can be a number in milliseconds or a Duration object */ - delayInterval: Date | number; + delayInterval: Duration | number; } export interface ExponentialBackoffRetryOptions { @@ -148,13 +148,20 @@ export interface ExponentialBackoffRetryOptions { /** * The minimum retry delay. - * This can be a Node.js Date or Unix time in milliseconds. + * This can be a number in milliseconds, or a Duration object */ - minimumInterval: Date | number; + minimumInterval: Duration | number; /** * The maximum retry delay. - * This can be a Node.js Date or Unix time in milliseconds. + * This can be a number in milliseconds, or a Duration object */ - maximumInterval: Date | number; + maximumInterval: Duration | number; +} + +export interface Duration { + hours?: number; + minutes?: number; + seconds?: number; + milliseconds?: number; }