diff --git a/src/app.ts b/src/app.ts index 48728da..721bd13 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 { toRpcDuration } from './converters/toRpcDuration'; 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: toRpcDuration((options.retry).delayInterval, 'retry.delayInterval'), + maximumInterval: toRpcDuration( + (options.retry).maximumInterval, + 'retry.maximumInterval' + ), + minimumInterval: toRpcDuration( + (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/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/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-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..43e8c49 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -112,3 +112,56 @@ 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 number in milliseconds or a Duration object + */ + delayInterval: Duration | 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 number in milliseconds, or a Duration object + */ + minimumInterval: Duration | number; + + /** + * The maximum retry delay. + * This can be a number in milliseconds, or a Duration object + */ + maximumInterval: Duration | number; +} + +export interface Duration { + hours?: number; + minutes?: number; + seconds?: number; + milliseconds?: 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