feat(durable-functions): restore worker-side callHttp (#318)#333
feat(durable-functions): restore worker-side callHttp (#318)#333YunchuWang wants to merge 2 commits into
Conversation
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<DurableHttpResponse>`. - 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
There was a problem hiding this comment.
Pull request overview
Restores the classic durable-functions v3 context.df.callHttp API for the durable-functions v4 Azure Functions compatibility package by rebuilding “durable HTTP” on top of durabletask gRPC core primitives (built-in activity + polling sub-orchestrator), including optional Managed Identity token acquisition and replay-safe 202 polling semantics.
Changes:
- Implemented built-in durable HTTP activity + polling orchestrator and wired
DurableOrchestrationContext.callHttp()to schedule it viacallSubOrchestrator. - Added v3-compatible durable HTTP request/response/token models and re-exported them from the package entrypoint.
- Added unit tests for HTTP behavior, polling, and import-time auto-registration; updated README and added
@azure/identityas an optional dependency.
Reviewed changes
Copilot reviewed 10 out of 11 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/azure-functions-durable/src/http/builtin.ts | Adds built-in HTTP activity (fetch) and replay-safe 202 polling orchestrator; optional Managed Identity token acquisition. |
| packages/azure-functions-durable/src/http/models.ts | Introduces v3-compatible durable HTTP request/response/token models and internal payload shape. |
| packages/azure-functions-durable/src/orchestration-context.ts | Replaces the previous throwing callHttp implementation with sub-orchestrator scheduling and request payload building. |
| packages/azure-functions-durable/src/app.ts | Auto-registers the built-in HTTP orchestrator/activity on module import. |
| packages/azure-functions-durable/src/index.ts | Re-exports durable HTTP models to preserve v3 import compatibility. |
| packages/azure-functions-durable/test/unit/http-builtin.spec.ts | Adds unit tests for HTTP activity, token acquisition path, and polling/orchestrator behavior. |
| packages/azure-functions-durable/test/unit/http-builtin-registration.spec.ts | Adds tests asserting import-time auto-registration into the Functions app and shared worker registry. |
| packages/azure-functions-durable/test/unit/orchestration-context.spec.ts | Updates tests to assert callHttp scheduling behavior instead of throwing. |
| packages/azure-functions-durable/README.md | Updates migration notes to reflect restored callHttp and documents the trust-boundary change + optional identity dependency. |
| packages/azure-functions-durable/package.json | Adds @azure/identity as an optional dependency. |
| package-lock.json | Updates lockfile for optional dependency addition and workspace metadata. |
| 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`.", | ||
| ); | ||
| } |
| if (resource) { | ||
| const token = await acquireBearerToken(resource); | ||
| if (headers["Authorization"] === undefined) { | ||
| headers["Authorization"] = `Bearer ${token}`; | ||
| } | ||
| } |
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
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 13 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (2)
packages/azure-functions-durable/src/http/builtin.ts:98
- The
require('@azure/identity')error handler catches all failures and always throws an "install @azure/identity" message. This will mask real runtime errors inside the dependency (or other require-time failures) and make diagnosing production issues harder. Only replace the error when the module is actually missing (MODULE_NOT_FOUND); otherwise rethrow the original error.
try {
identity = require("@azure/identity");
} catch {
throw new Error(
"callHttp with a tokenSource requires the optional '@azure/identity' package. " +
packages/azure-functions-durable/src/http/builtin.ts:145
- Managed-identity token injection only checks
headers["Authorization"], so a caller-suppliedauthorization(lowercase) header will be overwritten and/or duplicated. Since header names are case-insensitive, this can unexpectedly change the request's auth behavior.
const headers: { [key: string]: string } = { ...(request.headers ?? {}) };
const resource = request.tokenSource?.resource;
if (resource) {
const token = await acquireBearerToken(resource);
if (headers["Authorization"] === undefined) {
| - **`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<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 | ||
| 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` |
There was a problem hiding this comment.
make this more succinct and customer facing
What & why
Restores the classic durable-functions v3
context.df.callHttpAPI on the v4 gRPC-based provider (packages/azure-functions-durable), which currently throws. Fixes #318.The durabletask gRPC engine has no native durable-HTTP action, so the feature is rebuilt from core primitives. This ports Andy Staples' design from the sibling durabletask-python#155 (JS counterpart of #282).
Ported design (mirrors Python
builtin.py)BuiltIn__HttpActivity): performs a singlefetchrequest and returns{ statusCode, headers, content }. Non-2xx/202responses are captured, not thrown (globalfetchonly rejects on network errors). Includes an SSRF guard rejecting any non-http/httpsscheme, and acquires a Managed Identity bearer token when atokenSourceis supplied.BuiltIn__HttpPollOrchestrator): a core-nativeasync function*that calls the activity and, while the response is202 Acceptedwith aLocationheader, waits on a replay-safe durable timer (fireAt = currentUtcDateTime + delay, neverDate.now()) honoringRetry-After(delta-seconds and HTTP-date forms; 1s fallback), resolves a relativeLocationagainst the current URI, and re-polls. Honors the v3enablePollingopt-out.callHttp(options): builds the request payload and schedules the poll orchestrator viacallSubOrchestrator— a singleyield, durable polling — returningTask<DurableHttpResponse>.v3 API restored
Re-exports the v3 public shapes so v3 imports resolve unchanged:
DurableHttpRequest,DurableHttpResponse,CallHttpOptions,ManagedIdentityTokenSource,TokenSource.callHttpaccepts the v3CallHttpOptions(method,url,body,headers,tokenSource,enablePolling, deprecatedasynchronousPatternEnabledalias), JSON-stringifies an objectbody, and resolvesenablePolling = enablePolling ?? asynchronousPatternEnabled ?? true.In v3, the Functions host extension executed the HTTP request. In v4, it runs as a durable activity inside the app/worker process (via
fetch). Therefore the egress network path, outbound identity, and firewall/VNet behavior follow the worker process, not the host — re-verify egress and any IP allow-lists. This is documented in the README migration section.Managed identity (optional dependency)
Token acquisition uses
@azure/identityDefaultAzureCredential, loaded via a lazyrequireonly when atokenSourceis present (scope = resource.replace(/\/+$/,'') + '/.default'; sends the realBearer <token>). It is added as an optional dependency; if atokenSourceis used without the package installed, a clear "install@azure/identity" error is thrown. Apps that don't use managed identity need no extra install.Tests
urithrows; non-http/https scheme throws;tokenSourcesets a realAuthorization: Bearer <token>(credential mocked; asserts the real token);202captured (not thrown).202+Locationyields activity → timer → re-poll;Retry-Afterdelta-seconds and HTTP-date parsing; relativeLocationresolution;enablePolling=falsereturns the first202without looping.callHttpwiring: schedules the sub-orchestration underBUILTIN_HTTP_POLL_ORCHESTRATOR_NAMEwith the built payload; object body →JSON.stringify; defaultenablePollingtrue. Updated the existingorchestration-context.spec.tstest that previously assertedcallHttpthrows.Gates
npm run build:core— exit 0npm run build -w durable-functions— exit 0npm run lint— exit 0Credit: ported from durabletask-python#155 (Andy Staples). CHANGELOG intentionally left unchanged.
Fixes #318