Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/azure-functions-durable/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

### New

- Add opt-in `callHttp({ retryOptions: new HttpRetryOptions(...) })` failure retries using the core
durable activity retry engine, configurable status codes, backoff, attempt limits, and timeout.
Defaults match .NET in-process HTTP retries (non-2xx statuses, six-day maximum interval);
exhaustion throws `TaskFailedError`. Like .NET, `202` Location polls do not inherit the retry
policy. Calls without retry options are unchanged. See README for replay rollout and idempotency guidance.
- Expose readonly `context.df.name`, forwarding the core logical orchestration name from history
and preserving aliases and case across replay. This does not add `InvocationContext.functionName`.
- Add optional orchestration version migration support to `context.df.continueAsNew()`.
Expand Down
66 changes: 65 additions & 1 deletion packages/azure-functions-durable/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ changed:
([#318](https://github.com/microsoft/durabletask-js/issues/318)) — though **not** as a drop-in, fully
v3-equivalent replacement: the known incompatibilities and behavior differences listed below are
load-bearing for migration, so review them before relying on it. It accepts the v3
`CallHttpOptions` (`method`, `url`, `body`, `headers`, `tokenSource`, `enablePolling`) and returns a
`CallHttpOptions` (`method`, `url`, `body`, `headers`, `tokenSource`, `enablePolling`), adds opt-in
[`retryOptions`](#durable-http-failure-retries), and returns a
`Task<DurableHttpResponse>` (`{ statusCode, headers, content }`), including automatic `202 Accepted`
polling that honors `Retry-After` via durable timers. **Trust-boundary change:** in v3 the Functions
**host** extension executed the HTTP request; here it runs as a durable **activity inside your
Expand Down Expand Up @@ -130,6 +131,69 @@ changed:
sync **generators** (`function*`) using `context.df.*` — are unaffected; convert any non-generator
classic orchestrator to generator form, or to the core-native `ctx.*` API.

## Durable HTTP failure retries

Use `HttpRetryOptions` to retry failures of the **initial** `callHttp` request. It extends the existing
`RetryOptions` API; all intervals are milliseconds, and the attempt count includes the first request.

```typescript
import * as df from "durable-functions";
import type { Task } from "@microsoft/durabletask-js";

df.app.orchestration("FetchReport", function* (context: df.OrchestrationContext): Generator<
Task<unknown>,
unknown,
unknown
> {
const retryOptions = new df.HttpRetryOptions(1000, 4);
retryOptions.backoffCoefficient = 2;
retryOptions.maxRetryIntervalInMilliseconds = 10000;
retryOptions.retryTimeoutInMilliseconds = 60000;
retryOptions.statusCodesToRetry = [429, 500, 503];

return yield context.df.callHttp({
method: "GET",
url: context.df.getInput<string>(),
retryOptions,
});
});
```

The policy and a copy of the status list are recorded with the request. Retries reuse the core
activity retry engine and durable timers, so replay does not reissue completed network requests.
Selected failure responses have their unused body canceled before buffering, so even a body that
never ends does not prevent a durable retry. Returned responses still include their complete body.
Exhaustion throws `TaskFailedError` into the caller (or fails the orchestration if uncaught); it does
**not** return the last failed HTTP response. An excluded status is returned normally. Transport and
other activity failures use the same retry budget regardless of the status list. Retrying a request
can repeat side effects, including when a server processed it but the connection failed before the
response arrived. Enable retries only for operations that are safe to repeat.

Defaults follow the .NET **in-process Durable Functions extension**, not the generic .NET Durable
Task SDK: backoff `1`, maximum interval **six days**, and no overall retry timeout. An omitted or empty
status list retries non-2xx responses, including 4xx/5xx and any unfollowed 3xx, matching
[`TaskHttpActivityShim`'s `EnsureSuccessStatusCode`](https://github.com/Azure/azure-functions-durable-extension/blob/4437edf785533ccdf219c15f496777ba1c30d41d/src/WebJobs.Extensions.DurableTask/Listener/TaskHttpActivityShim.cs#L63-L88).
An explicit list is exact, even for success codes: listing `202` retries the initial request instead
of following its Location. Existing `fetch` redirect behavior is unchanged.

**202 polling is separate:** like the extension's
[`CreateLocationPollRequest`](https://github.com/Azure/azure-functions-durable-extension/blob/4437edf785533ccdf219c15f496777ba1c30d41d/src/WebJobs.Extensions.DurableTask/ContextImplementations/DurableOrchestrationContext.cs#L283-L379),
Location polls **do not inherit the failure retry policy**. For example, initial `503 -> 202` can retry,
then a poll returning `503` ends the call with that response, without retrying the poll. The existing
polling `Retry-After` delays and credential-stripping rules remain unchanged. Failure retry delays use
the configured policy, not `Retry-After`. `enablePolling: false` disables only polling, not retries.

JavaScript-specific behavior: options use millisecond numbers rather than .NET `TimeSpan`; `-1` means
unlimited maximum interval or retry timeout. Scalar validation and timeout boundaries reuse the core
`RetryPolicy`: no retry is scheduled if its delay exceeds the remaining timeout, equality is allowed,
and the timeout does not cancel an in-flight HTTP request or limit the subsequent polling loop.
Long retry delays use the provider's existing three-day durable timer segments. Failures surface as
JavaScript `TaskFailedError`, not .NET exception types.

Omitting `retryOptions` preserves existing response, failure, and polling behavior. Deploy the updated
worker before enabling the option; do not add it to existing in-flight orchestration code without
versioning or draining those instances, because it changes the durable action history.

## Requirements

This provider reaches the Durable Task backend over the Functions host's **gRPC** channel, which
Expand Down
31 changes: 24 additions & 7 deletions packages/azure-functions-durable/src/http/builtin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
* durabletask-python design (Andy Staples, durabletask-python#155).
*/

import { OrchestrationContext, Task } from "@microsoft/durabletask-js";
import { OrchestrationContext, RetryPolicy, Task } from "@microsoft/durabletask-js";
import { DurableHttpRequestPayload, DurableHttpResponse } from "./models";

/**
Expand Down Expand Up @@ -234,10 +234,10 @@ async function acquireBearerToken(resource: string): Promise<string> {
*
* @remarks
* `input` is the JSON form of a durable HTTP request (`method`, `uri`, `content`, `headers`,
* `tokenSource`). Non-2xx responses (including `202`) are captured rather than thrown — the global
* `fetch` only rejects on network errors, not on HTTP status — so the poll orchestrator can inspect
* the status code and headers. Only http/https URIs are permitted (an SSRF guard that closes off
* `file://`, `ftp://`, ... schemes from orchestration-supplied URLs).
* `tokenSource`, `retryOptions`). Responses are returned unless an opt-in retry policy selects the
* status as a failure. Activity failures are retried by the orchestrator's durable retry policy,
* never by an in-process retry loop. Only http/https URIs are permitted (an SSRF guard that closes
* off `file://`, `ftp://`, ... schemes from orchestration-supplied URLs).
*
* When a `tokenSource` is present, the acquired bearer token **overwrites** any caller-supplied
* `Authorization` header (matching v3, which applies the caller's headers first and then the token),
Expand Down Expand Up @@ -299,6 +299,18 @@ export async function builtinHttpActivity(input: DurableHttpRequestPayload): Pro
body: includeBody ? request.content : undefined,
});

if (request.retryOptions !== undefined) {
const codes = request.retryOptions.statusCodesToRetry;
const shouldRetry = codes?.length
? codes.includes(response.status)
: response.status < 200 || response.status >= 300;
if (shouldRetry) {
// The discarded body may never end. Cancel it before buffering so durable retries can proceed.
await response.body?.cancel();
throw new Error(`HTTP request failed with status code ${response.status}.`);
}
}

const responseHeaders: { [key: string]: string } = {};
response.headers.forEach((value, key) => {
responseHeaders[key] = value;
Expand Down Expand Up @@ -345,6 +357,7 @@ function buildPollRequest(
enablePolling: boolean,
): DurableHttpRequestPayload {
const sameOrigin = isSameOrigin(trustAnchorUri, resolved);
// Like .NET CreateLocationPollRequest, Location polls do not inherit the initial retry policy.
const pollRequest: DurableHttpRequestPayload = { method: "GET", uri: resolved, enablePolling };

if (request.headers !== undefined) {
Expand Down Expand Up @@ -390,10 +403,14 @@ export async function* builtinHttpPollOrchestrator(
}

const request = input ?? ({} as DurableHttpRequestPayload);
// v3 opt-out: when polling is disabled the first response is returned as-is (no 202 loop).
// v3 opt-out: skip the 202 loop when polling is disabled; failure retries are independent.
const enablePolling = request.enablePolling !== false;

let response = (yield ctx.callActivity(BUILTIN_HTTP_ACTIVITY_NAME, request)) as BuiltinHttpActivityResult;
const initialTask =
request.retryOptions === undefined
? ctx.callActivity(BUILTIN_HTTP_ACTIVITY_NAME, request)
: ctx.callActivity(BUILTIN_HTTP_ACTIVITY_NAME, request, { retry: new RetryPolicy(request.retryOptions) });
let response = (yield initialTask) as BuiltinHttpActivityResult;
// Two DISTINCT URIs, deliberately NOT merged into one variable:
// - `originalUri` is the credential TRUST ANCHOR: the URI the app author declared. It is captured
// once and NEVER reassigned, so a callee-controlled `Location` (or a redirect `fetch` followed)
Expand Down
24 changes: 23 additions & 1 deletion packages/azure-functions-durable/src/http/models.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

import { RetryOptions } from "../retry-options";

/**
* Durable HTTP request/response models that **mirror** the durable-functions v3 shapes (they are not
* the v3 classes themselves).
Expand Down Expand Up @@ -43,8 +45,24 @@ export class ManagedIdentityTokenSource {
}

/**
* Options accepted by `context.df.callHttp` (classic durable-functions v3 shape).
* Opt-in failure retries for the initial `callHttp` request (not subsequent `202` Location polls).
* Uses durable activity retries; exhausting the policy fails `callHttp` with `TaskFailedError`.
* Only enable retries when repeating the request's side effects is safe.
*/
export class HttpRetryOptions extends RetryOptions {
/** Maximum delay between retries, in milliseconds. Defaults to six days, matching .NET HTTP retries. */
public override maxRetryIntervalInMilliseconds?: number = 6 * 24 * 60 * 60 * 1000;

/**
* Response status codes that fail the activity and trigger retries. Omitted/empty means every
* non-2xx response (including 4xx/5xx and unfollowed redirects), matching .NET EnsureSuccessStatusCode.
* A non-empty list is exact: even `202` can be retried instead of polled. Excluded codes are returned.
* Activity failures, including transport errors, are retried regardless of this list.
*/
public statusCodesToRetry?: number[];
}

/** Options accepted by `context.df.callHttp`. */
export interface CallHttpOptions {
/** The HTTP request method. */
method: string;
Expand All @@ -56,6 +74,8 @@ export interface CallHttpOptions {
headers?: { [key: string]: string };
/** The source of the OAuth token to add to the request. */
tokenSource?: TokenSource;
/** Optional durable failure retry policy for the initial request only. Omitted means no retries. */
retryOptions?: HttpRetryOptions;
/**
* Whether to keep polling the request after receiving a `202 Accepted` response. Replaces the
* deprecated `asynchronousPatternEnabled`; if both are specified, `enablePolling` takes precedence.
Expand Down Expand Up @@ -135,4 +155,6 @@ export interface DurableHttpRequestPayload {
tokenSource?: { kind?: string; resource: string };
/** Whether to keep polling on `202 Accepted`. Defaults to `true` when absent. */
enablePolling?: boolean;
/** Serializable retry policy for this request; methods are never included in history. */
retryOptions?: Omit<HttpRetryOptions, "toRetryPolicy">;
}
2 changes: 1 addition & 1 deletion packages/azure-functions-durable/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export { OrchestrationFilter } from "./orchestration-filter";
// Durable HTTP (classic v3 `context.df.callHttp`) models. Value exports (classes) and type exports
// so `import { DurableHttpRequest, ManagedIdentityTokenSource, CallHttpOptions, DurableHttpResponse,
// TokenSource } from ...` resolves as it did in durable-functions v3.
export { DurableHttpRequest, ManagedIdentityTokenSource } from "./http/models";
export { DurableHttpRequest, HttpRetryOptions, ManagedIdentityTokenSource } from "./http/models";
export type { CallHttpOptions, DurableHttpResponse, TokenSource } from "./http/models";

// Legacy durable-functions v3 API compatibility aliases (types only). These let orchestrator/
Expand Down
17 changes: 17 additions & 0 deletions packages/azure-functions-durable/src/orchestration-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,10 @@ export class DurableOrchestrationContext {
* endpoint returns `202 Accepted`, waits on durable timers (honoring `Retry-After`) and re-polls
* until completion. The whole flow is a single `yield`, preserving the v3 ergonomics.
*
* `retryOptions` enables durable failure retries for the initial request only. Exhaustion throws
* `TaskFailedError` into the calling generator; excluded HTTP statuses are returned unchanged.
* Like the .NET in-process extension, subsequent `202` Location polls do not inherit this policy.
*
* Trust-boundary change vs v3: the HTTP request now runs as a durable **activity inside the app /
* worker process** (via `fetch`), not in the Functions host extension. Outbound network path,
* identity, and firewall/VNet behavior therefore follow the worker process. Managed-identity
Expand Down Expand Up @@ -195,6 +199,19 @@ export class DurableOrchestrationContext {
if (options.tokenSource !== undefined) {
request.tokenSource = { kind: options.tokenSource.kind, resource: options.tokenSource.resource };
}
if (options.retryOptions !== undefined) {
const policy = options.retryOptions.toRetryPolicy();
request.retryOptions = {
firstRetryIntervalInMilliseconds: policy.firstRetryIntervalInMilliseconds,
maxNumberOfAttempts: policy.maxNumberOfAttempts,
backoffCoefficient: policy.backoffCoefficient,
maxRetryIntervalInMilliseconds: policy.maxRetryIntervalInMilliseconds,
retryTimeoutInMilliseconds: policy.retryTimeoutInMilliseconds,
};
if (options.retryOptions.statusCodesToRetry != null) {
request.retryOptions.statusCodesToRetry = [...options.retryOptions.statusCodesToRetry];
}
}
return this._ctx.callSubOrchestrator<DurableHttpRequestPayload, DurableHttpResponse>(
BUILTIN_HTTP_POLL_ORCHESTRATOR_NAME,
request,
Expand Down
40 changes: 39 additions & 1 deletion packages/azure-functions-durable/test/unit/http-builtin.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
isSameOrigin,
retryAfterSeconds,
} from "../../src/http/builtin";
import { DurableHttpRequestPayload, DurableHttpResponse } from "../../src/http/models";
import { DurableHttpRequestPayload, DurableHttpResponse, HttpRetryOptions } from "../../src/http/models";

// `@azure/identity` is an OPTIONAL peer dependency loaded lazily via `require` inside the activity.
// A `{ virtual: true }` mock stands in so the token-acquisition path can be exercised and the REAL
Expand Down Expand Up @@ -180,6 +180,44 @@ describe("builtinHttpActivity", () => {
});
});

it.each([429, 503])("cancels a selected %s body without buffering it", async (status) => {
const cancel = jest.fn(async () => undefined);
const text = jest.fn(async () => "discarded");
const response = {
...fakeResponse(status, {}, ""),
body: { cancel },
text,
};
global.fetch = makeFetchMock(response) as unknown as typeof fetch;
await expect(
builtinHttpActivity({ method: "POST", uri: "http://127.0.0.1/", retryOptions: new HttpRetryOptions(100, 2) }),
).rejects.toThrow(`HTTP request failed with status code ${status}.`);
expect(cancel).toHaveBeenCalledTimes(1);
expect(text).not.toHaveBeenCalled();
});

it("fails a selected status with a null response body", async () => {
global.fetch = jest.fn(async () => new Response(null, { status: 503 }));
await expect(
builtinHttpActivity({ method: "HEAD", uri: "http://127.0.0.1/", retryOptions: new HttpRetryOptions(100, 2) }),
).rejects.toThrow("HTTP request failed with status code 503.");
});

it("surfaces a body cancellation failure rather than silently discarding it", async () => {
const cleanupError = new Error("body cancellation failed");
const text = jest.fn(async () => "discarded");
const response = {
...fakeResponse(503, {}, ""),
body: { cancel: jest.fn().mockRejectedValue(cleanupError) },
text,
};
global.fetch = makeFetchMock(response) as unknown as typeof fetch;
await expect(
builtinHttpActivity({ method: "POST", uri: "http://127.0.0.1/", retryOptions: new HttpRetryOptions(100, 2) }),
).rejects.toBe(cleanupError);
expect(text).not.toHaveBeenCalled();
});

it("sends a body for non-GET/HEAD methods", async () => {
const fetchMock = makeFetchMock(fakeResponse(200, {}, ""));
global.fetch = fetchMock as unknown as typeof fetch;
Expand Down
Loading
Loading