From 6267ba7b0d45271d8f5d24d6bc28fe246bd2de59 Mon Sep 17 00:00:00 2001 From: wangbill Date: Fri, 24 Jul 2026 09:51:40 -0700 Subject: [PATCH 1/2] feat(durable-functions): restore worker-side callHttp (#318) Restore the classic durable-functions v3 `context.df.callHttp` API on the v4 gRPC-based provider. The durabletask gRPC engine has no native durable-HTTP action, so the feature is reconstructed from core primitives, porting Andy Staples' durabletask-python#155 design: - Built-in HTTP activity (`BuiltIn__HttpActivity`): performs one `fetch` request, captures non-2xx/202 responses instead of throwing, guards against non-http(s) schemes (SSRF), and acquires a Managed Identity bearer token via the optional `@azure/identity` package when a tokenSource is supplied. - Built-in poll orchestrator (`BuiltIn__HttpPollOrchestrator`): a core-native async generator that re-polls while the endpoint returns `202 Accepted` with a `Location`, waiting on replay-safe durable timers that honor `Retry-After` (delta-seconds and HTTP-date forms), resolving relative Locations, and honoring the v3 `enablePolling` opt-out. - `callHttp(options)` builds the request and schedules the poll orchestrator as a sub-orchestration (single yield), returning `Task`. - Both builtins auto-register once when the package is imported. Also restores the v3 public types (`DurableHttpRequest`, `DurableHttpResponse`, `CallHttpOptions`, `ManagedIdentityTokenSource`, `TokenSource`) and documents the trust-boundary change: the HTTP request now runs as a durable activity in the app/worker process (via `fetch`), not in the Functions host extension, so egress path, outbound identity, and firewall/VNet behavior differ from v3. Managed identity requires the optional `@azure/identity` package. Fixes #318 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e1120c1a-9b20-4aef-b34a-f7986dd06382 --- package-lock.json | 4 + packages/azure-functions-durable/README.md | 17 +- packages/azure-functions-durable/package.json | 3 + packages/azure-functions-durable/src/app.ts | 15 + .../src/http/builtin.ts | 222 +++++++++++++ .../src/http/models.ts | 133 ++++++++ packages/azure-functions-durable/src/index.ts | 6 + .../src/orchestration-context.ts | 39 ++- .../unit/http-builtin-registration.spec.ts | 47 +++ .../test/unit/http-builtin.spec.ts | 291 ++++++++++++++++++ .../test/unit/orchestration-context.spec.ts | 53 +++- 11 files changed, 818 insertions(+), 12 deletions(-) create mode 100644 packages/azure-functions-durable/src/http/builtin.ts create mode 100644 packages/azure-functions-durable/src/http/models.ts create mode 100644 packages/azure-functions-durable/test/unit/http-builtin-registration.spec.ts create mode 100644 packages/azure-functions-durable/test/unit/http-builtin.spec.ts diff --git a/package-lock.json b/package-lock.json index f5976749..5e3a8f42 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "durabletask-js-monorepo", "version": "0.0.0", + "hasInstallScript": true, "license": "MIT", "workspaces": [ "packages/*" @@ -7587,6 +7588,9 @@ }, "engines": { "node": ">=22.0.0" + }, + "optionalDependencies": { + "@azure/identity": "^4.0.0" } }, "packages/azure-functions-durable/node_modules/@types/node": { diff --git a/packages/azure-functions-durable/README.md b/packages/azure-functions-durable/README.md index d45ea14d..cc648f15 100644 --- a/packages/azure-functions-durable/README.md +++ b/packages/azure-functions-durable/README.md @@ -44,12 +44,19 @@ changed: `LockHandle` — call `release()`, ideally in a `finally`) and query with `context.entities.isInCriticalSection()`. Restoring the v3 `df.lock` / `isLocked` surface is tracked in [#317](https://github.com/microsoft/durabletask-js/issues/317). -- **`context.df.callHttp(...)` now throws.** v3 ran durable HTTP as a host-managed activity; the - consolidated gRPC backend has no equivalent primitive. Implement an HTTP activity in your app and - call it from the orchestrator. Restoring `callHttp` as a worker-side durable activity is tracked in - [#318](https://github.com/microsoft/durabletask-js/issues/318). +- **`context.df.callHttp(...)` is restored** as a worker-side durable HTTP call + ([#318](https://github.com/microsoft/durabletask-js/issues/318)). It accepts the v3 + `CallHttpOptions` (`method`, `url`, `body`, `headers`, `tokenSource`, `enablePolling`) and returns a + `Task` (`{ 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 + app/worker process** (via `fetch`). Outbound network path, source identity, and firewall/VNet rules + therefore follow the worker process, not the host — re-verify egress and any IP allow-lists. A + managed-identity `tokenSource` requires the optional + [`@azure/identity`](https://www.npmjs.com/package/@azure/identity) package + (`npm install @azure/identity`); without it, a request that uses a `tokenSource` throws a clear error. - **Some v3 top-level exports were removed** — `DummyOrchestrationContext` / `DummyEntityContext` - (testing utilities), `ManagedIdentityTokenSource`, and the entity-lock types above. `TaskFailedError` + (testing utilities) and the entity-lock types above. `TaskFailedError` is re-exported from the core SDK (aggregate failures surface as JS-native `AggregateError`); use the core `TestOrchestrationWorker` / `TestOrchestrationClient` for orchestration unit tests. - **A plain non-generator classic orchestrator is no longer supported.** A classic v3 orchestrator diff --git a/packages/azure-functions-durable/package.json b/packages/azure-functions-durable/package.json index 778799c6..70d16811 100644 --- a/packages/azure-functions-durable/package.json +++ b/packages/azure-functions-durable/package.json @@ -52,6 +52,9 @@ "@grpc/grpc-js": "^1.14.4", "@microsoft/durabletask-js": "0.4.0" }, + "optionalDependencies": { + "@azure/identity": "^4.0.0" + }, "devDependencies": { "@types/jest": "^29.5.1", "@types/node": "^22.0.0", diff --git a/packages/azure-functions-durable/src/app.ts b/packages/azure-functions-durable/src/app.ts index fccaab51..b1105233 100644 --- a/packages/azure-functions-durable/src/app.ts +++ b/packages/azure-functions-durable/src/app.ts @@ -13,6 +13,12 @@ import * as trigger from "./trigger"; import { ClassicEntity, wrapEntity } from "./entity-context"; import { ClassicOrchestrator, wrapOrchestrator } from "./orchestration-context"; import { DurableFunctionsWorker } from "./worker"; +import { + BUILTIN_HTTP_ACTIVITY_NAME, + BUILTIN_HTTP_POLL_ORCHESTRATOR_NAME, + builtinHttpActivity, + builtinHttpPollOrchestrator, +} from "./http/builtin"; // The `client` namespace groups the durable-client starter registration helpers so that // `df.app.client.http(...)` (and siblings) restore the v3 client-starter sugar. `index.ts` @@ -157,3 +163,12 @@ function extractBase64Request(triggerInput: unknown): string { } throw new TypeError("Durable trigger did not provide a base64-encoded request body."); } + +// Auto-register the built-in durable-HTTP functions exactly once when this module is first imported, +// so classic `context.df.callHttp` works with no user registration. The poll orchestrator is a +// core-native `async function*` (passed through by `wrapOrchestrator`) and the activity is a plain +// host-dispatched handler. Names are reserved (see `./http/builtin`); registering here — rather than +// per app instance — means they are wired once for the whole function app. Ported from the +// durabletask-python design (Andy Staples, durabletask-python#155). +orchestration(BUILTIN_HTTP_POLL_ORCHESTRATOR_NAME, { handler: builtinHttpPollOrchestrator }); +activity(BUILTIN_HTTP_ACTIVITY_NAME, { handler: builtinHttpActivity }); diff --git a/packages/azure-functions-durable/src/http/builtin.ts b/packages/azure-functions-durable/src/http/builtin.ts new file mode 100644 index 00000000..7fa50ef9 --- /dev/null +++ b/packages/azure-functions-durable/src/http/builtin.ts @@ -0,0 +1,222 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +/** + * Built-in durable HTTP support for the Azure Functions compatibility layer. + * + * @remarks + * The v3 `context.df.callHttp` API relied on the Durable Functions host extension to execute the + * HTTP request (including automatic `202 Accepted` polling and Managed Identity token acquisition). + * The durabletask gRPC engine this provider is built on has no native durable-HTTP action, so the + * feature is reconstructed here from core primitives: + * + * - a built-in **activity** ({@link builtinHttpActivity}) performs a single HTTP request — acquiring + * a bearer token via the optional `@azure/identity` package when a token source is supplied — and + * returns the response, and + * - a built-in **poll orchestrator** ({@link builtinHttpPollOrchestrator}) issues the request and, + * while the endpoint returns `202` with a `Location` header, waits on a durable timer (honoring + * `Retry-After`) and re-polls until the operation completes. + * + * `DurableOrchestrationContext.callHttp` schedules the poll orchestrator as a sub-orchestration, + * preserving the single-`yield` v3 ergonomics while keeping the 202 polling loop durable + * (checkpointed across restarts). + * + * Both functions are auto-registered under reserved names when this package is imported (see + * `../app.ts`) so existing apps that call `callHttp` work with no changes. Ported from the + * durabletask-python design (Andy Staples, durabletask-python#155). + */ + +import { OrchestrationContext, Task } from "@microsoft/durabletask-js"; +import { DurableHttpRequestPayload, DurableHttpResponse } from "./models"; + +/** + * Reserved built-in function names. The v3 host used `BuiltIn::HttpActivity`; `::` is not a valid + * Azure Functions function name, so `__` is used here. The reserved names are unlikely to collide + * with user-defined functions. + */ +export const BUILTIN_HTTP_ACTIVITY_NAME = "BuiltIn__HttpActivity"; +export const BUILTIN_HTTP_POLL_ORCHESTRATOR_NAME = "BuiltIn__HttpPollOrchestrator"; + +/** Fallback interval (seconds) between polls when a `202` response carries no usable `Retry-After`. */ +const DEFAULT_POLL_INTERVAL_SECONDS = 1; + +/** Case-insensitively look up `name` in `headers`. */ +function getHeader(headers: { [key: string]: string }, name: string): string | undefined { + const lowered = name.toLowerCase(); + for (const key of Object.keys(headers)) { + if (key.toLowerCase() === lowered) { + return headers[key]; + } + } + return undefined; +} + +/** + * Parse the `Retry-After` header into a delay in seconds. + * + * @remarks + * Supports both the delta-seconds and HTTP-date forms; falls back to + * {@link DEFAULT_POLL_INTERVAL_SECONDS} when absent or unparseable. For the HTTP-date form the delay + * is computed against `now` — which the caller supplies as the orchestration's replay-safe + * `currentUtcDateTime` — so the resulting timer fire time is deterministic across replays. + * + * @internal Exported for unit testing. + */ +export function retryAfterSeconds(headers: { [key: string]: string }, now: Date): number { + const raw = getHeader(headers, "Retry-After"); + if (raw === undefined || raw === null) { + return DEFAULT_POLL_INTERVAL_SECONDS; + } + const trimmed = raw.trim(); + if (/^\d+$/.test(trimmed)) { + return Math.max(parseInt(trimmed, 10), 0); + } + const retryAtMs = Date.parse(trimmed); + if (Number.isNaN(retryAtMs)) { + return DEFAULT_POLL_INTERVAL_SECONDS; + } + return Math.max(Math.floor((retryAtMs - now.getTime()) / 1000), 0); +} + +/** + * Acquire an AAD bearer token for `resource` via the optional `@azure/identity` package. + * + * @remarks + * Loaded lazily with `require` (mirroring the core SDK's optional-peer-dependency pattern) so the + * dependency is only touched when a token source is actually used; `require` also keeps the module + * out of the compiled type graph, so an app that never uses a token source needs no `@azure/identity` + * install. Throws a clear, actionable error when the package is missing but a token source was used. + */ +async function acquireBearerToken(resource: string): Promise { + let identity: { + DefaultAzureCredential: new () => { getToken(scope: string): Promise<{ token: string } | null> }; + }; + try { + identity = require("@azure/identity"); + } catch { + throw new Error( + "callHttp with a tokenSource requires the optional '@azure/identity' package. " + + "Install it with `npm install @azure/identity`.", + ); + } + const credential = new identity.DefaultAzureCredential(); + const scope = resource.replace(/\/+$/, "") + "/.default"; + const result = await credential.getToken(scope); + const token = result?.token; + if (!token) { + throw new Error(`Failed to acquire a bearer token for resource '${resource}'.`); + } + return token; +} + +/** + * Built-in activity: execute a single HTTP request and return the response. + * + * @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). + */ +export async function builtinHttpActivity(input: DurableHttpRequestPayload): Promise { + const request = input ?? ({} as DurableHttpRequestPayload); + const method = String(request.method ?? "GET").toUpperCase(); + const uri = request.uri; + if (!uri) { + throw new Error("A non-empty 'uri' is required for a durable HTTP call."); + } + // Durable HTTP only ever means http(s); reject other schemes (file://, ftp://, ...) that fetch + // (or a redirect) might otherwise honor, closing off local-file reads / SSRF to non-HTTP endpoints. + let scheme: string; + try { + scheme = new URL(uri).protocol.replace(/:$/, "").toLowerCase(); + } catch { + throw new Error(`callHttp only supports http/https URLs; got ${JSON.stringify(uri)}.`); + } + if (scheme !== "http" && scheme !== "https") { + throw new Error(`callHttp only supports http/https URLs; got ${JSON.stringify(uri)}.`); + } + + const headers: { [key: string]: string } = { ...(request.headers ?? {}) }; + const resource = request.tokenSource?.resource; + if (resource) { + const token = await acquireBearerToken(resource); + if (headers["Authorization"] === undefined) { + headers["Authorization"] = `Bearer ${token}`; + } + } + + // `content` was already serialized to a string by `callHttp`, so it is sent as-is. GET/HEAD + // requests cannot carry a body under the fetch spec, so a body is only attached for other methods. + const includeBody = typeof request.content === "string" && method !== "GET" && method !== "HEAD"; + const response = await fetch(uri, { + method, + headers, + body: includeBody ? request.content : undefined, + }); + + const responseHeaders: { [key: string]: string } = {}; + response.headers.forEach((value, key) => { + responseHeaders[key] = value; + }); + const content = await response.text(); + + return { statusCode: response.status, headers: responseHeaders, content }; +} + +/** + * Built-in poll orchestrator: issue a durable HTTP request and poll while it returns `202`. + * + * @remarks + * Written as a core-native async generator so the durabletask engine drives it directly (the + * orchestration input arrives as the second argument). It calls the built-in HTTP activity and, + * while the response is `202 Accepted` with a `Location` header (and polling is enabled), waits on a + * durable timer (honoring `Retry-After`) before re-polling the `Location` URL, resolving a relative + * `Location` against the current request URI. Returns the final response. All time math uses the + * replay-safe `currentUtcDateTime`, never `Date.now()`, so replays are deterministic. + */ +export async function* builtinHttpPollOrchestrator( + ctx: OrchestrationContext, + input: DurableHttpRequestPayload, +): AsyncGenerator, DurableHttpResponse, unknown> { + const request = input ?? ({} as DurableHttpRequestPayload); + // v3 opt-out: when polling is disabled the first response is returned as-is (no 202 loop). + const enablePolling = request.enablePolling !== false; + + let response = (yield ctx.callActivity(BUILTIN_HTTP_ACTIVITY_NAME, request)) as DurableHttpResponse; + // Track the URI of the most recent request so a relative `Location` can be resolved against it. + let currentUri = String(request.uri ?? ""); + + while (enablePolling && response.statusCode === 202) { + const headers = response.headers ?? {}; + const location = getHeader(headers, "Location"); + if (!location) { + // Cannot poll without a Location; return the 202 as-is. + break; + } + + // A `Location` may be relative (e.g. `/operations/42`); resolve it against the current request + // URI so the next poll targets an absolute http(s) URL (the activity rejects non-absolute URIs). + const resolved = new URL(location, currentUri).toString(); + + const now = ctx.currentUtcDateTime; + const delaySeconds = retryAfterSeconds(headers, now); + const fireAt = new Date(now.getTime() + delaySeconds * 1000); + yield ctx.createTimer(fireAt); + + const pollRequest: DurableHttpRequestPayload = { method: "GET", uri: resolved, enablePolling }; + // Preserve auth for the polling requests. + if (request.headers !== undefined) { + pollRequest.headers = request.headers; + } + if (request.tokenSource !== undefined) { + pollRequest.tokenSource = request.tokenSource; + } + + currentUri = resolved; + response = (yield ctx.callActivity(BUILTIN_HTTP_ACTIVITY_NAME, pollRequest)) as DurableHttpResponse; + } + + return response; +} diff --git a/packages/azure-functions-durable/src/http/models.ts b/packages/azure-functions-durable/src/http/models.ts new file mode 100644 index 00000000..7e824b68 --- /dev/null +++ b/packages/azure-functions-durable/src/http/models.ts @@ -0,0 +1,133 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +/** + * Durable HTTP request/response models (durable-functions v3 compatible). + * + * @remarks + * In v3 the Durable Functions host extension executed `context.df.callHttp` natively (including + * `202 Accepted` polling and Managed Identity token acquisition). The durabletask gRPC engine this + * provider is built on has no native durable-HTTP action, so the feature is reconstructed from core + * primitives (a built-in activity + a built-in polling sub-orchestration — see `./builtin`). These + * types mirror the v3 public shapes and double as the JSON wire payload exchanged with the built-in + * activity. Ported from the durabletask-python design (Andy Staples, durabletask-python#155). + */ + +/** + * The source of an OAuth token to attach to a durable HTTP request. + * + * @remarks + * Mirrors the classic durable-functions v3 `TokenSource` union, which currently has a single + * implementation, {@link ManagedIdentityTokenSource}. + */ +export type TokenSource = ManagedIdentityTokenSource; + +/** + * Token source backed by an [Azure Managed Identity](https://learn.microsoft.com/azure/active-directory/managed-identities-azure-resources/overview). + * + * @remarks + * v3-compatible: exposes the target `resource` and a `kind` discriminator. When a request carrying + * this token source reaches the built-in HTTP activity, a bearer token is acquired for `resource` + * via the optional `@azure/identity` package and added as an `Authorization: Bearer ` header. + */ +export class ManagedIdentityTokenSource { + /** @hidden Discriminator matching the classic durable-functions v3 token-source shape. */ + readonly kind: string = "AzureManagedIdentity"; + + /** + * @param resource The Azure Active Directory resource identifier of the web API being invoked, + * e.g. `https://management.core.windows.net/` or `https://graph.microsoft.com/`. + */ + constructor(public readonly resource: string) {} +} + +/** + * Options accepted by `context.df.callHttp` (classic durable-functions v3 shape). + */ +export interface CallHttpOptions { + /** The HTTP request method. */ + method: string; + /** The HTTP request URL. */ + url: string; + /** The HTTP request body. An `object` is JSON-serialized; a `string` is sent as-is. */ + body?: string | object; + /** The HTTP request headers. */ + headers?: { [key: string]: string }; + /** The source of the OAuth token to add to the request. */ + tokenSource?: TokenSource; + /** + * Whether to keep polling the request after receiving a `202 Accepted` response. Replaces the + * deprecated `asynchronousPatternEnabled`; if both are specified, `enablePolling` takes precedence. + * + * @default true + */ + enablePolling?: boolean; + /** + * @deprecated Use `enablePolling` instead. If both are specified, `enablePolling` takes precedence. + */ + asynchronousPatternEnabled?: boolean; +} + +/** + * The response returned by `context.df.callHttp` (classic durable-functions v3 shape). + * + * @remarks + * The value crosses the sub-orchestration boundary as JSON, so `callHttp` resolves to a plain object + * of this shape. Consumers read `response.statusCode` / `response.content` / `response.headers`. + */ +export interface DurableHttpResponse { + /** The HTTP response status code. */ + statusCode: number; + /** The HTTP response headers (keys are lower-cased by the underlying `fetch` implementation). */ + headers: { [key: string]: string }; + /** The HTTP response body. */ + content?: string; +} + +/** + * Data structure representing a durable HTTP request (classic durable-functions v3 shape). + * + * @remarks + * Exported for import compatibility with durable-functions v3. `callHttp` builds the internal + * {@link DurableHttpRequestPayload} wire form directly rather than constructing this class. + */ +export class DurableHttpRequest { + /** + * @param method The HTTP request method. + * @param uri The HTTP request URL. + * @param content The HTTP request content. + * @param headers The HTTP request headers. + * @param tokenSource The source of the OAuth token to add to the request. + * @param asynchronousPatternEnabled Whether the request should handle the asynchronous (202) pattern. + */ + constructor( + public readonly method: string, + public readonly uri: string, + public readonly content?: string, + public readonly headers?: { [key: string]: string }, + public readonly tokenSource?: TokenSource, + public readonly asynchronousPatternEnabled: boolean = true, + ) {} +} + +/** + * @hidden + * Internal JSON wire payload exchanged between `callHttp`, the built-in poll orchestrator, and the + * built-in HTTP activity. Uses `uri` (not `url`) and pre-serialized `content` (not `body`), matching + * the v3 `DurableHttpRequest` wire shape, plus an `enablePolling` flag so the poll orchestrator can + * honor the v3 opt-out of `202` polling. + */ +export interface DurableHttpRequestPayload { + /** The HTTP request method. */ + method: string; + /** The absolute http(s) request URI. */ + uri: string; + /** The pre-serialized HTTP request body. */ + content?: string; + /** The HTTP request headers. */ + headers?: { [key: string]: string }; + /** The OAuth token source (JSON form); only `resource` is required by the activity. */ + tokenSource?: { kind?: string; resource: string }; + /** Whether to keep polling on `202 Accepted`. Defaults to `true` when absent. */ + enablePolling?: boolean; +} diff --git a/packages/azure-functions-durable/src/index.ts b/packages/azure-functions-durable/src/index.ts index 514ca36e..0b6fe23f 100644 --- a/packages/azure-functions-durable/src/index.ts +++ b/packages/azure-functions-durable/src/index.ts @@ -47,6 +47,12 @@ export { EntityStateResponse } from "./entity-state-response"; export { PurgeHistoryResult } from "./purge-history-result"; 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 type { CallHttpOptions, DurableHttpResponse, TokenSource } from "./http/models"; + // Legacy durable-functions v3 API compatibility aliases (types only). These let orchestrator/ // activity code written against the classic `durable-functions` v3 API type-check unchanged. export type { ActivityHandler } from "./app"; diff --git a/packages/azure-functions-durable/src/orchestration-context.ts b/packages/azure-functions-durable/src/orchestration-context.ts index e839d41d..451a7753 100644 --- a/packages/azure-functions-durable/src/orchestration-context.ts +++ b/packages/azure-functions-durable/src/orchestration-context.ts @@ -12,6 +12,8 @@ import { whenAny, } from "@microsoft/durabletask-js"; import { RetryOptions } from "./retry-options"; +import { BUILTIN_HTTP_POLL_ORCHESTRATOR_NAME } from "./http/builtin"; +import { CallHttpOptions, DurableHttpRequestPayload, DurableHttpResponse } from "./http/models"; /** * Classic Durable Functions (v3) orchestration context, exposed to migrating orchestrators as @@ -150,11 +152,40 @@ export class DurableOrchestrationContext { * Schedules a durable HTTP call (classic v3 API). * * @remarks - * Not supported: the durabletask engine has no durable-HTTP (`callHttp`) equivalent, so this - * throws. This mirrors the Python provider, which raises for the same reason. + * The durabletask gRPC engine has no native durable-HTTP action (unlike the v3 Durable Functions + * host extension, which executed the request itself), so this is reconstructed from core + * primitives: the request is handed to a built-in polling sub-orchestration + * ({@link BUILTIN_HTTP_POLL_ORCHESTRATOR_NAME}) that runs a built-in HTTP activity and, while the + * 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. + * + * 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 + * token acquisition (`tokenSource`) requires the optional `@azure/identity` package. + * + * @returns A {@link Task} resolving to the final {@link DurableHttpResponse}. */ - callHttp(_options: unknown): never { - throw new Error("callHttp is not supported: the durabletask engine has no durable-HTTP (callHttp) equivalent."); + callHttp(options: CallHttpOptions): Task { + const enablePolling = options.enablePolling ?? options.asynchronousPatternEnabled ?? true; + const request: DurableHttpRequestPayload = { + method: options.method, + uri: options.url, + enablePolling, + }; + if (options.body !== undefined) { + request.content = typeof options.body === "string" ? options.body : JSON.stringify(options.body); + } + if (options.headers !== undefined) { + request.headers = options.headers; + } + if (options.tokenSource !== undefined) { + request.tokenSource = { kind: options.tokenSource.kind, resource: options.tokenSource.resource }; + } + return this._ctx.callSubOrchestrator( + BUILTIN_HTTP_POLL_ORCHESTRATOR_NAME, + request, + ); } /** Calls an entity operation and waits for its result. */ diff --git a/packages/azure-functions-durable/test/unit/http-builtin-registration.spec.ts b/packages/azure-functions-durable/test/unit/http-builtin-registration.spec.ts new file mode 100644 index 00000000..3d788cc4 --- /dev/null +++ b/packages/azure-functions-durable/test/unit/http-builtin-registration.spec.ts @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { BUILTIN_HTTP_ACTIVITY_NAME, BUILTIN_HTTP_POLL_ORCHESTRATOR_NAME } from "../../src/http/builtin"; + +// The built-in durable-HTTP functions are auto-registered as a side effect of importing `../../src/app`. +// `jest.doMock` (not hoisted) lets us install a fresh `app.generic` spy BEFORE the module is required +// inside `jest.isolateModules`, so the import-time registration calls are captured deterministically. +describe("built-in durable HTTP auto-registration", () => { + it("registers both built-in functions when the app module is imported", () => { + const mockGeneric = jest.fn(); + + jest.isolateModules(() => { + jest.doMock("@azure/functions", () => { + const actual = jest.requireActual("@azure/functions"); + return { ...actual, app: { ...actual.app, generic: mockGeneric } }; + }); + require("../../src/app"); + }); + + const registeredNames = mockGeneric.mock.calls.map((call) => call[0] as string); + expect(registeredNames).toContain(BUILTIN_HTTP_POLL_ORCHESTRATOR_NAME); + expect(registeredNames).toContain(BUILTIN_HTTP_ACTIVITY_NAME); + + const pollRegistration = mockGeneric.mock.calls.find( + (call) => call[0] === BUILTIN_HTTP_POLL_ORCHESTRATOR_NAME, + ); + const activityRegistration = mockGeneric.mock.calls.find((call) => call[0] === BUILTIN_HTTP_ACTIVITY_NAME); + + // The poll orchestrator is wired as a durable orchestration trigger; the HTTP worker as an activity. + expect((pollRegistration?.[1] as { trigger: { type: string } }).trigger.type).toBe("orchestrationTrigger"); + expect((activityRegistration?.[1] as { trigger: { type: string } }).trigger.type).toBe("activityTrigger"); + }); + + it("registers the built-in orchestrator on the shared worker so it can be dispatched by name", () => { + let registeredOrchestratorNames: string[] = []; + + jest.isolateModules(() => { + const { getSharedWorker } = require("../../src/app") as typeof import("../../src/app"); + // `_registry` is a TypeScript-private field (accessible at runtime) exposing the registered names. + const worker = getSharedWorker() as unknown as { _registry: { getOrchestratorNames(): string[] } }; + registeredOrchestratorNames = worker._registry.getOrchestratorNames(); + }); + + expect(registeredOrchestratorNames).toContain(BUILTIN_HTTP_POLL_ORCHESTRATOR_NAME); + }); +}); diff --git a/packages/azure-functions-durable/test/unit/http-builtin.spec.ts b/packages/azure-functions-durable/test/unit/http-builtin.spec.ts new file mode 100644 index 00000000..06311dfa --- /dev/null +++ b/packages/azure-functions-durable/test/unit/http-builtin.spec.ts @@ -0,0 +1,291 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { OrchestrationContext, Task } from "@microsoft/durabletask-js"; +import { + BUILTIN_HTTP_ACTIVITY_NAME, + builtinHttpActivity, + builtinHttpPollOrchestrator, + retryAfterSeconds, +} from "../../src/http/builtin"; +import { DurableHttpRequestPayload, DurableHttpResponse } from "../../src/http/models"; + +// `@azure/identity` is an OPTIONAL dependency loaded lazily via `require` inside the activity, and is +// not installed in this workspace — a virtual mock stands in so the token-acquisition path can be +// exercised and the REAL (mocked) token asserted on the outgoing request. +const mockGetToken = jest.fn(async (_scope: string) => ({ token: "REAL_TOKEN_123" })); +jest.mock( + "@azure/identity", + () => ({ + DefaultAzureCredential: jest.fn().mockImplementation(() => ({ getToken: mockGetToken })), + }), + { virtual: true }, +); + +/** A minimal fetch Response stand-in (avoids depending on the global `Response` constructor). */ +function fakeResponse(status: number, headers: { [key: string]: string }, body: string) { + return { + status, + headers: { + forEach: (cb: (value: string, key: string) => void) => + Object.entries(headers).forEach(([key, value]) => cb(value, key)), + }, + text: async () => body, + }; +} + +/** + * Builds a `fetch` mock with a typed `(input, init)` signature so `mock.calls[i][1]` is a + * `RequestInit` (a bare `jest.fn(async () => ...)` types its calls as an empty tuple). + */ +function makeFetchMock(response: ReturnType) { + return jest.fn((_input: string, _init?: RequestInit) => Promise.resolve(response)); +} + +describe("retryAfterSeconds", () => { + const now = new Date("2026-01-01T00:00:00.000Z"); + + it("parses the delta-seconds form", () => { + expect(retryAfterSeconds({ "Retry-After": "5" }, now)).toBe(5); + }); + + it("parses the HTTP-date form relative to the replay-safe clock", () => { + expect(retryAfterSeconds({ "Retry-After": "Thu, 01 Jan 2026 00:00:10 GMT" }, now)).toBe(10); + }); + + it("is case-insensitive on the header name", () => { + expect(retryAfterSeconds({ "retry-after": "7" }, now)).toBe(7); + }); + + it("falls back to 1s when the header is missing or unparseable", () => { + expect(retryAfterSeconds({}, now)).toBe(1); + expect(retryAfterSeconds({ "Retry-After": "not-a-date" }, now)).toBe(1); + }); + + it("never returns a negative delay for a past HTTP-date", () => { + expect(retryAfterSeconds({ "Retry-After": "Thu, 01 Jan 2026 00:00:00 GMT" }, new Date("2026-01-01T00:01:00.000Z"))).toBe( + 0, + ); + }); +}); + +describe("builtinHttpActivity", () => { + const originalFetch = global.fetch; + + afterEach(() => { + global.fetch = originalFetch; + jest.clearAllMocks(); + }); + + it("performs the request and passes the 200 response through", async () => { + const fetchMock = makeFetchMock(fakeResponse(200, { "content-type": "text/plain" }, "hello")); + global.fetch = fetchMock as unknown as typeof fetch; + + const response = await builtinHttpActivity({ method: "GET", uri: "https://example.test/data" }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [calledUri, calledInit] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(calledUri).toBe("https://example.test/data"); + expect(calledInit.method).toBe("GET"); + expect(calledInit.body).toBeUndefined(); + expect(response).toEqual({ + statusCode: 200, + headers: { "content-type": "text/plain" }, + content: "hello", + }); + }); + + it("sends a body for non-GET/HEAD methods but omits it for GET", async () => { + const fetchMock = makeFetchMock(fakeResponse(200, {}, "")); + global.fetch = fetchMock as unknown as typeof fetch; + + await builtinHttpActivity({ method: "POST", uri: "https://example.test/", content: '{"a":1}' }); + expect((fetchMock.mock.calls[0][1] as RequestInit).body).toBe('{"a":1}'); + + await builtinHttpActivity({ method: "GET", uri: "https://example.test/", content: '{"a":1}' }); + expect((fetchMock.mock.calls[1][1] as RequestInit).body).toBeUndefined(); + }); + + it("throws when the uri is missing", async () => { + global.fetch = jest.fn() as unknown as typeof fetch; + await expect(builtinHttpActivity({ method: "GET" } as DurableHttpRequestPayload)).rejects.toThrow(/uri/i); + }); + + it("rejects non-http/https schemes (SSRF guard)", async () => { + global.fetch = jest.fn() as unknown as typeof fetch; + await expect( + builtinHttpActivity({ method: "GET", uri: "file:///etc/passwd" }), + ).rejects.toThrow(/http\/https/); + await expect(builtinHttpActivity({ method: "GET", uri: "ftp://host/f" })).rejects.toThrow(/http\/https/); + }); + + it("captures a 202 response instead of throwing", async () => { + const fetchMock = makeFetchMock(fakeResponse(202, { location: "https://example.test/op/1" }, "")); + global.fetch = fetchMock as unknown as typeof fetch; + + const response = await builtinHttpActivity({ method: "POST", uri: "https://example.test/start" }); + + expect(response.statusCode).toBe(202); + expect(response.headers.location).toBe("https://example.test/op/1"); + }); + + it("acquires a real bearer token via @azure/identity when a tokenSource is present", async () => { + const fetchMock = makeFetchMock(fakeResponse(200, {}, "ok")); + global.fetch = fetchMock as unknown as typeof fetch; + + await builtinHttpActivity({ + method: "GET", + uri: "https://example.test/secure", + tokenSource: { kind: "AzureManagedIdentity", resource: "https://management.core.windows.net/" }, + }); + + // The scope is the resource with any trailing slashes stripped, plus `/.default`. + expect(mockGetToken).toHaveBeenCalledWith("https://management.core.windows.net/.default"); + const sentHeaders = (fetchMock.mock.calls[0][1] as RequestInit).headers as { [key: string]: string }; + // The REAL token is forwarded — never a masked placeholder. + expect(sentHeaders["Authorization"]).toBe("Bearer REAL_TOKEN_123"); + }); + + it("does not overwrite an explicit Authorization header", async () => { + const fetchMock = makeFetchMock(fakeResponse(200, {}, "ok")); + global.fetch = fetchMock as unknown as typeof fetch; + + await builtinHttpActivity({ + method: "GET", + uri: "https://example.test/secure", + headers: { Authorization: "Bearer caller-supplied" }, + tokenSource: { resource: "https://graph.microsoft.com/" }, + }); + + const sentHeaders = (fetchMock.mock.calls[0][1] as RequestInit).headers as { [key: string]: string }; + expect(sentHeaders["Authorization"]).toBe("Bearer caller-supplied"); + }); +}); + +/** Drives the poll orchestrator generator with a fake core context, feeding activity/timer results. */ +function createPollContext(now: Date) { + const ctx = { + currentUtcDateTime: now, + callActivity: jest.fn((name: string, input: unknown) => ({ kind: "activity", name, input }) as unknown as Task), + createTimer: jest.fn((fireAt: Date | number) => ({ kind: "timer", fireAt }) as unknown as Task), + }; + return { ctx: ctx as unknown as OrchestrationContext, raw: ctx }; +} + +describe("builtinHttpPollOrchestrator", () => { + const now = new Date("2026-01-01T00:00:00.000Z"); + + it("returns the first response immediately when it is not a 202 (no timer)", async () => { + const { ctx, raw } = createPollContext(now); + const gen = builtinHttpPollOrchestrator(ctx, { method: "GET", uri: "https://host/api", enablePolling: true }); + + const firstYield = await gen.next(); + expect(raw.callActivity).toHaveBeenCalledWith( + BUILTIN_HTTP_ACTIVITY_NAME, + expect.objectContaining({ method: "GET", uri: "https://host/api" }), + ); + expect(firstYield.done).toBe(false); + + const ok: DurableHttpResponse = { statusCode: 200, headers: {}, content: "done" }; + const result = await gen.next(ok); + expect(result.done).toBe(true); + expect(result.value).toEqual(ok); + expect(raw.createTimer).not.toHaveBeenCalled(); + }); + + it("polls on 202+Location: activity, then durable timer, then re-polls Location by GET", async () => { + const { ctx, raw } = createPollContext(now); + const request: DurableHttpRequestPayload = { + method: "POST", + uri: "https://host/api/start", + enablePolling: true, + headers: { "x-custom": "1" }, + tokenSource: { resource: "https://management.core.windows.net/" }, + }; + const gen = builtinHttpPollOrchestrator(ctx, request); + + // 1) initial activity + await gen.next(); + expect(raw.callActivity).toHaveBeenNthCalledWith(1, BUILTIN_HTTP_ACTIVITY_NAME, request); + + // 2) 202 -> a durable timer honoring Retry-After (5s) is created + const afterFirst = await gen.next({ + statusCode: 202, + headers: { Location: "https://host/api/status/1", "Retry-After": "5" }, + }); + expect(afterFirst.done).toBe(false); + expect(raw.createTimer).toHaveBeenCalledTimes(1); + expect(raw.createTimer).toHaveBeenCalledWith(new Date("2026-01-01T00:00:05.000Z")); + + // 3) after the timer, re-poll the Location with GET, carrying headers + tokenSource + const afterTimer = await gen.next(); + expect(afterTimer.done).toBe(false); + expect(raw.callActivity).toHaveBeenNthCalledWith(2, BUILTIN_HTTP_ACTIVITY_NAME, { + method: "GET", + uri: "https://host/api/status/1", + enablePolling: true, + headers: { "x-custom": "1" }, + tokenSource: { resource: "https://management.core.windows.net/" }, + }); + + // 4) final 200 completes the orchestration + const done = await gen.next({ statusCode: 200, headers: {}, content: "final" }); + expect(done.done).toBe(true); + expect(done.value).toEqual({ statusCode: 200, headers: {}, content: "final" }); + }); + + it("honors an HTTP-date Retry-After when scheduling the poll timer", async () => { + const { ctx, raw } = createPollContext(now); + const gen = builtinHttpPollOrchestrator(ctx, { method: "GET", uri: "https://host/api", enablePolling: true }); + + await gen.next(); + await gen.next({ + statusCode: 202, + headers: { Location: "https://host/api/status", "Retry-After": "Thu, 01 Jan 2026 00:00:30 GMT" }, + }); + expect(raw.createTimer).toHaveBeenCalledWith(new Date("2026-01-01T00:00:30.000Z")); + }); + + it("resolves a relative Location against the current request URI", async () => { + const { ctx, raw } = createPollContext(now); + const gen = builtinHttpPollOrchestrator(ctx, { method: "POST", uri: "https://host/api/op", enablePolling: true }); + + await gen.next(); + await gen.next({ statusCode: 202, headers: { Location: "/status/42", "Retry-After": "1" } }); + await gen.next(); // advance past the timer to the second poll + + expect(raw.callActivity).toHaveBeenNthCalledWith( + 2, + BUILTIN_HTTP_ACTIVITY_NAME, + expect.objectContaining({ method: "GET", uri: "https://host/status/42" }), + ); + }); + + it("returns the first 202 without looping when polling is disabled", async () => { + const { ctx, raw } = createPollContext(now); + const gen = builtinHttpPollOrchestrator(ctx, { method: "GET", uri: "https://host/api", enablePolling: false }); + + await gen.next(); + const result = await gen.next({ + statusCode: 202, + headers: { Location: "https://host/api/status", "Retry-After": "5" }, + }); + + expect(result.done).toBe(true); + expect((result.value as DurableHttpResponse).statusCode).toBe(202); + expect(raw.createTimer).not.toHaveBeenCalled(); + expect(raw.callActivity).toHaveBeenCalledTimes(1); + }); + + it("stops polling when a 202 has no Location header", async () => { + const { ctx, raw } = createPollContext(now); + const gen = builtinHttpPollOrchestrator(ctx, { method: "GET", uri: "https://host/api", enablePolling: true }); + + await gen.next(); + const result = await gen.next({ statusCode: 202, headers: {} }); + + expect(result.done).toBe(true); + expect((result.value as DurableHttpResponse).statusCode).toBe(202); + expect(raw.createTimer).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/azure-functions-durable/test/unit/orchestration-context.spec.ts b/packages/azure-functions-durable/test/unit/orchestration-context.spec.ts index add2a1bf..eb8f7e2d 100644 --- a/packages/azure-functions-durable/test/unit/orchestration-context.spec.ts +++ b/packages/azure-functions-durable/test/unit/orchestration-context.spec.ts @@ -21,6 +21,7 @@ import { wrapOrchestrator, } from "../../src/orchestration-context"; import { RetryOptions } from "../../src/retry-options"; +import { BUILTIN_HTTP_POLL_ORCHESTRATOR_NAME } from "../../src/http/builtin"; /** Builds a fake core OrchestrationContext whose methods return sentinel values via jest mocks. */ function createFakeCoreContext() { @@ -150,11 +151,57 @@ describe("DurableOrchestrationContext", () => { expect(entities.signalEntity).toHaveBeenCalledWith(entityId, "reset", undefined); }); - it("throws for callHttp, which has no durabletask equivalent", () => { - const { ctx } = createFakeCoreContext(); + it("schedules callHttp as the built-in poll sub-orchestration with the built request payload", () => { + const { ctx, raw } = createFakeCoreContext(); + const df = new DurableOrchestrationContext(ctx, undefined); + + // A string body is sent as-is; polling defaults to enabled. + const task = df.callHttp({ method: "GET", url: "https://example.test/api", headers: { "x-a": "1" } }); + expect(task).toBe("callSub-task"); + expect(raw.callSubOrchestrator).toHaveBeenCalledWith(BUILTIN_HTTP_POLL_ORCHESTRATOR_NAME, { + method: "GET", + uri: "https://example.test/api", + enablePolling: true, + headers: { "x-a": "1" }, + }); + }); + + it("JSON-stringifies an object body and forwards the token source when scheduling callHttp", () => { + const { ctx, raw } = createFakeCoreContext(); + const df = new DurableOrchestrationContext(ctx, undefined); + + df.callHttp({ + method: "POST", + url: "https://example.test/api", + body: { hello: "world" }, + tokenSource: { kind: "AzureManagedIdentity", resource: "https://management.core.windows.net/" } as never, + }); + + expect(raw.callSubOrchestrator).toHaveBeenCalledWith(BUILTIN_HTTP_POLL_ORCHESTRATOR_NAME, { + method: "POST", + uri: "https://example.test/api", + enablePolling: true, + content: JSON.stringify({ hello: "world" }), + tokenSource: { kind: "AzureManagedIdentity", resource: "https://management.core.windows.net/" }, + }); + }); + + it("honors enablePolling=false (and the deprecated asynchronousPatternEnabled alias) for callHttp", () => { + const { ctx, raw } = createFakeCoreContext(); const df = new DurableOrchestrationContext(ctx, undefined); - expect(() => df.callHttp({ method: "GET", uri: "https://example.test" })).toThrow(/callHttp/); + df.callHttp({ method: "GET", url: "https://example.test/api", enablePolling: false }); + expect(raw.callSubOrchestrator).toHaveBeenLastCalledWith( + BUILTIN_HTTP_POLL_ORCHESTRATOR_NAME, + expect.objectContaining({ enablePolling: false }), + ); + + // enablePolling takes precedence over the deprecated alias when both are present. + df.callHttp({ method: "GET", url: "https://example.test/api", asynchronousPatternEnabled: false }); + expect(raw.callSubOrchestrator).toHaveBeenLastCalledWith( + BUILTIN_HTTP_POLL_ORCHESTRATOR_NAME, + expect.objectContaining({ enablePolling: false }), + ); }); it("exposes Task.all / Task.any that forward to the core combinators", () => { From 81c6000c23e52a6ac9f326904cafe4b68a76ad34 Mon Sep 17 00:00:00 2001 From: wangbill Date: Fri, 24 Jul 2026 16:24:20 -0700 Subject: [PATCH 2/2] test(e2e-functions): add callHttp host e2e (sync/polling/no-poll) Add end-to-end callHttp coverage to the gated Functions-host suite (test/e2e-functions). The suite previously had zero callHttp coverage; this drives the restored context.df.callHttp API through a real func host + Azurite over three modes: - sync: callHttp -> 200 passthrough (HttpEcho). - polling: callHttp follows a 202 -> Location poll loop to a final 200 (HttpAsyncEcho, stateless attempt-encoded 202 so it is deterministic across worker processes). - nopoll: enablePolling:false returns the first 202 without looping. The test-app functions target the app's own loopback endpoints so the suite stays hermetic (no external network). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e1120c1a-9b20-4aef-b34a-f7986dd06382 --- test/e2e-functions/call-http.spec.ts | 67 ++++++++++++++ .../src/functions/CallHttpOrchestration.ts | 91 +++++++++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 test/e2e-functions/call-http.spec.ts create mode 100644 test/e2e-functions/test-app/src/functions/CallHttpOrchestration.ts diff --git a/test/e2e-functions/call-http.spec.ts b/test/e2e-functions/call-http.spec.ts new file mode 100644 index 00000000..d29df1d3 --- /dev/null +++ b/test/e2e-functions/call-http.spec.ts @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +/** + * End-to-end coverage for the restored v3 `context.df.callHttp` API. + * + * Drives the `CallHttpOrchestration` app function (see test-app) through the real + * Functions host: a durable callHttp against the app's own endpoints exercises the + * synchronous 200 path, the 202 -> Location poll loop, and the enablePolling=false + * opt-out. Kept hermetic (loopback only) so no external network is required. + * + * Gated: skips cleanly unless the shared host was started by globalSetup. + */ + +import { + invokeHttpTrigger, + parseStatusQueryGetUri, + readPreflight, + waitForOrchestrationState, +} from "./harness"; + +const preflight = readPreflight(); +const describeMaybe = preflight.ok ? describe : describe.skip; +const baseUrl = preflight.baseUrl ?? ""; + +if (!preflight.ok) { + console.warn(`[functions-e2e] call-http.spec skipped: ${preflight.reason}`); +} + +describeMaybe("Functions host E2E — callHttp (AzureStorage)", () => { + it("callHttp returns a synchronous 200 response", async () => { + const response = await invokeHttpTrigger(baseUrl, "CallHttp_HttpStart", "?mode=sync"); + expect(response.status).toBe(202); // HttpStatusCode.Accepted (check-status payload) + + const statusQueryGetUri = parseStatusQueryGetUri(response); + const details = await waitForOrchestrationState(statusQueryGetUri, "Completed", 60); + + const output = details.output as { statusCode: number; content: string }; + expect(output.statusCode).toBe(200); + expect(JSON.parse(output.content).echoed).toBe("hello"); + }, 120_000); + + it("callHttp follows the 202 -> Location poll loop to the final 200", async () => { + const response = await invokeHttpTrigger(baseUrl, "CallHttp_HttpStart", "?mode=polling"); + expect(response.status).toBe(202); + + const statusQueryGetUri = parseStatusQueryGetUri(response); + const details = await waitForOrchestrationState(statusQueryGetUri, "Completed", 60); + + const output = details.output as { statusCode: number; content: string }; + expect(output.statusCode).toBe(200); + expect(JSON.parse(output.content).echoed).toBe("async-done"); + }, 120_000); + + it("callHttp with enablePolling:false returns the 202 without polling", async () => { + const response = await invokeHttpTrigger(baseUrl, "CallHttp_HttpStart", "?mode=nopoll"); + expect(response.status).toBe(202); + + const statusQueryGetUri = parseStatusQueryGetUri(response); + const details = await waitForOrchestrationState(statusQueryGetUri, "Completed", 60); + + // enablePolling=false returns the first 202 as-is; a 202 carries no body, so + // only the status code is asserted (do not JSON.parse the empty content). + const output = details.output as { statusCode: number; content: string }; + expect(output.statusCode).toBe(202); + }, 120_000); +}); diff --git a/test/e2e-functions/test-app/src/functions/CallHttpOrchestration.ts b/test/e2e-functions/test-app/src/functions/CallHttpOrchestration.ts new file mode 100644 index 00000000..b820c851 --- /dev/null +++ b/test/e2e-functions/test-app/src/functions/CallHttpOrchestration.ts @@ -0,0 +1,91 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Exercises the restored v3 `context.df.callHttp` API end-to-end. The orchestration +// calls the app's OWN http endpoints (HttpEcho / HttpAsyncEcho) so the suite stays +// hermetic — no external network is required. HttpAsyncEcho drives a stateless +// 202 -> Location -> 200 poll loop keyed off an `attempt` query param. + +import { app, HttpHandler, HttpRequest, HttpResponse, HttpResponseInit, InvocationContext } from '@azure/functions'; +import * as df from 'durable-functions'; +import { CallHttpOptions, OrchestrationContext, OrchestrationHandler } from 'durable-functions'; + +// Orchestration: issue a single durable callHttp and return the final status + body. +const CallHttpOrchestration: OrchestrationHandler = function* (context: OrchestrationContext) { + const input = context.df.getInput<{ url: string; enablePolling?: boolean }>(); + const options: CallHttpOptions = { method: 'GET', url: input.url }; + if (input.enablePolling !== undefined) { + options.enablePolling = input.enablePolling; + } + const response = (yield context.df.callHttp(options)) as { statusCode: number; content?: string }; + return { statusCode: response.statusCode, content: response.content }; +}; +df.app.orchestration('CallHttpOrchestration', CallHttpOrchestration); + +// Downstream endpoint (sync path): echoes the `value` query param as JSON. +const HttpEcho: HttpHandler = async (request: HttpRequest, _context: InvocationContext): Promise => { + return { + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ echoed: request.query.get('value') ?? 'default', method: request.method }), + }; +}; +app.http('HttpEcho', { + route: 'HttpEcho', + methods: ['GET', 'POST'], + authLevel: 'anonymous', + handler: HttpEcho, +}); + +// Downstream endpoint (async 202 pattern): stateless poll loop keyed off `attempt`. +// The first request returns 202 with a Location pointing at attempt+1; the next +// returns the final 200. Encoding attempt in the URL keeps it deterministic across +// worker processes (no module-level state). +const HttpAsyncEcho: HttpHandler = async (request: HttpRequest, _context: InvocationContext): Promise => { + const attempt = parseInt(request.query.get('attempt') ?? '1', 10); + if (attempt < 2) { + const next = new URL(request.url); + next.searchParams.set('attempt', String(attempt + 1)); + return { + status: 202, + headers: { Location: next.toString(), 'Retry-After': '1' }, + }; + } + return { + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ echoed: 'async-done', attempt }), + }; +}; +app.http('HttpAsyncEcho', { + route: 'HttpAsyncEcho', + methods: ['GET'], + authLevel: 'anonymous', + handler: HttpAsyncEcho, +}); + +// HTTP starter: schedule CallHttpOrchestration pointed at one of the app's own +// endpoints. `mode` selects the sync (HttpEcho), polling, or no-poll variant. +const CallHttp_HttpStart: HttpHandler = async (request: HttpRequest, context: InvocationContext): Promise => { + const client = df.getClient(context); + const origin = new URL(request.url).origin; + const mode = request.query.get('mode') ?? 'sync'; + + let input: { url: string; enablePolling?: boolean }; + if (mode === 'sync') { + input = { url: `${origin}/api/HttpEcho?value=hello` }; + } else if (mode === 'nopoll') { + input = { url: `${origin}/api/HttpAsyncEcho`, enablePolling: false }; + } else { + input = { url: `${origin}/api/HttpAsyncEcho` }; + } + + const instanceId = await client.startNew('CallHttpOrchestration', { input }); + return client.createCheckStatusResponse(request, instanceId); +}; +app.http('CallHttp_HttpStart', { + route: 'CallHttp_HttpStart', + extraInputs: [df.input.durableClient()], + methods: ['GET', 'POST'], + handler: CallHttp_HttpStart, +});