feat(durable-functions): restore worker-side callHttp (#318) - #333
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. |
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) {
…creds, top-level guard, compat) Address code-review defects in the restored context.df.callHttp: - P0-1: strip caller credentials on a cross-origin 202 Location poll (Authorization/Cookie/tokenSource dropped cross-origin; x-functions-key always dropped), with a defensive per-iteration header copy so stripping one hop never corrupts a later same-origin hop. Mirrors Azure/azure-functions-durable-extension#3443. - P0-2: reject a top-level start of BuiltIn__HttpPollOrchestrator (it is only ever a callHttp sub-orchestration) to close an SSRF / managed-identity token-minting vector. - P1-3: make the AAD /.default scope idempotent (bare and already-scoped resources). - P1-4: a tokenSource now overwrites any caller Authorization case-insensitively. - P1-5: resolve a relative Location against the effective (post-redirect) URI. - P1-6: default poll interval 1s -> 30s (host default); round the HTTP-date form up. - P1-7: throw on a GET/HEAD request body instead of silently dropping it. - P1-8 (documented gap): DurableHttpResponse stays a plain object across the sub-orchestration JSON boundary, so v3's getHeader() is unavailable; read response.headers["name"]. Restoring it safely needs a wrapOrchestrator drive-loop rewrite that risks classic-orchestrator error propagation; deferred to a follow-up. Tests: rewrite the http-builtin unit spec (cross/same-origin, multi-hop, no-parent guard, scope idempotency, auth override, retry-after 30s/ceil, GET/HEAD body throw); extend the host e2e with same-origin-forwards and cross-origin-strips cases proving the policy end-to-end against a real func host. README updated. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d86f5f69-4e1a-4c1d-b017-5e290c85cc05
… guard; document deferred redirect/getHeader gaps Follow-up to ad3ae85 (append-only); no runtime behavior change. - e2e (P0-2 proof): start BuiltIn__HttpPollOrchestrator directly by name via the generic StartOrchestration starter and assert it reaches Failed carrying the top-level-guard message. This proves ctx.parent IS populated on the Functions host path (real func + Azurite: call-http 6/6 passed). - P0-1 (redirect): document why builtinHttpActivity keeps the default redirect:"follow" -- undici drops Authorization/Cookie on cross-origin 3xx per the Fetch Standard but NOT custom headers such as x-functions-key; a redirect:"manual" loop is deferred as a single-request behavior change the hermetic loopback e2e cannot cover. The higher-severity 202 Location poll loop (which re-mints Managed-Identity tokens) is fully guarded regardless. - P1-8 (getHeader): document why v3 DurableHttpResponse.getHeader() is not restored across the sub-orchestration JSON boundary (core Task is a plain data holder read directly by the executor). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d86f5f69-4e1a-4c1d-b017-5e290c85cc05
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 13 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (2)
packages/azure-functions-durable/src/http/builtin.ts:165
- The
require("@azure/identity")error handling currently catches all failures and rethrows an "install @azure/identity" message. That can mask real load-time problems (e.g.,ERR_REQUIRE_ESM, dependency initialization errors) when the package is present but fails to load.
try {
identity = require("@azure/identity");
} catch {
throw new Error(
"callHttp with a tokenSource requires the optional '@azure/identity' package. " +
test/e2e-functions/test-app/src/functions/CallHttpOrchestration.ts:89
locationis built using${u.protocol}//localhost:${u.port}/.... Ifrequest.urlhas no explicit port (default 80/443),u.portis an empty string and this produces an invalid URL likehttps://localhost:/api/HttpAuthEcho(extra colon).
const u = new URL(request.url);
const location = `${u.protocol}//localhost:${u.port}/api/HttpAuthEcho`;
…lHttp poll trust anchor (P0-1 anchor-drift) Follow-up to 5015da6 (append-only). Fixes a Managed-Identity token exfiltration bypass in builtinHttpPollOrchestrator. The 202 poll loop used `currentUri` -- reassigned each hop to the callee-controlled `response.effectiveUri` -- as the same-origin trust anchor passed to buildPollRequest. Once any hop landed on an attacker origin (via a 202 Location, or a cross-origin 3xx that fetch follows), the anchor drifted there, so the next attacker->attacker hop was judged "same-origin" and buildPollRequest re-derived the pristine original Authorization/Cookie AND tokenSource -- minting a fresh MI token for the original resource and sending it to the attacker. Fix: separate the two roles that were sharing one variable. - originalUri = credential trust anchor: the originally-requested request.uri, captured once and never reassigned. Same-origin is now always judged against it, so a callee-controlled Location/redirect cannot move the origin we send credentials to. Mirrors .NET DurableOrchestrationContext.CallHttpAsync, whose `req` is never reassigned across hops. - currentUri = relative-Location resolution base only (the effective post-redirect URI), per RFC 9110 s10.2.2. Behavior unchanged. Rename buildPollRequest `base` param to `trustAnchorUri`. Behavioral consequence (same as .NET, safe default): if a service legitimately 302s to a different host and then polls there, credentials are now stripped. Tests (written RED first, confirmed failing on 5015da6 -- both leaked Authorization+Cookie -- then green after the fix): - double-202 cross-origin chain: the attacker's own same-origin Location must not restore creds/tokenSource. - redirect-moved anchor: a first hop whose effective URI is a cross-origin redirect target must still strip creds on the first poll. Existing same-origin multi-hop forwarding and relative-Location-against- effective-URI tests remain green (no over-stripping). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d86f5f69-4e1a-4c1d-b017-5e290c85cc05
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 13 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (1)
packages/azure-functions-durable/src/http/builtin.ts:238
- builtinHttpActivity currently relies on fetch’s default redirect behavior ("follow"). As noted in the comment, cross-origin redirects can forward custom headers (e.g., x-functions-key) to the redirect target, which is a credential-leak vector if the initial URI responds with an attacker-controlled redirect. Consider failing closed on cross-origin redirects (or using redirect:"manual" and explicitly re-applying the same-origin credential policy per hop), or document this as a deliberate security/compat tradeoff so users don’t assume the cross-origin stripping applies to redirects as well.
// `redirect: "follow"` (the default): `fetch`/undici transparently follows 3xx redirects and, per the
// Fetch Standard, drops `Authorization` (and `Cookie`) when a redirect crosses origins — but it does
// NOT drop custom credential headers such as `x-functions-key`. Switching to `redirect: "manual"` and
// re-applying our cross-origin policy per hop would close that residual gap, but it would change
// observable single-request semantics (hop count, effective URL, cookie handling) in ways the
…s-new
The default (auto-derived) child instance ID was `${parentInstanceId}:${seqHex}`.
Both inputs are constant across a continue-as-new generation: the parent instance
ID is unchanged by continue-as-new, and the per-work-item sequence number resets to
0 on every work item. continue-as-new truncates history, so the new generation has
no SubOrchestrationInstanceCreated event to dedupe against and emits a genuine, first
-time CreateSubOrchestration for a child instance ID that still exists in the backend
-> "Orchestration instance '<P>:0001' already exists". Replay-dedupe is scoped to one
execution, but the ID was scoped to the instance; continue-as-new splits those scopes.
This newly bit v4 callHttp, which schedules the built-in HTTP poll orchestrator as a
default-ID sub-orchestration, so callHttp -> continueAsNew -> callHttp failed (v3 was
immune because callHttp was a host-registered activity, never an instance).
Fix: include the per-execution executionId (fresh on every generation) in the derived
child ID -> `${parentInstanceId}:${executionId}:${seqHex}`. Mirrors DurableTask.Core
TaskOrchestrationContext (ExecutionId + ":" + id) and the fresh ExecutionId minted on
continue-as-new in TaskOrchestrationDispatcher. Falls back to the legacy
`${parentInstanceId}:${seqHex}` when executionId is empty, so backends that do not
populate it are left exactly as they are today (no regression) rather than newly broken.
To make executionId actually available on the in-memory path (where it was always ""):
- newExecutionStartedEvent gains an optional executionId param that sets the proto
OrchestrationInstance.executionId.
- InMemoryOrchestrationBackend mints a fresh executionId on createInstance and a NEW one
on continue-as-new, stores it on the instance record, and threads it through.
- OrchestrationExecutor.execute accepts an optional authoritative executionId (seeded
from the gRPC OrchestratorRequest / backend record); the replayed ExecutionStarted
value still applies and wins, and a mismatch is logged (not thrown).
Replay-safe for in-flight orchestrations: handleSubOrchestrationCreated matches on
taskId + action type + sub-orchestration name only, never the recorded instance ID, so
histories that recorded old-format child IDs still match on replay and do not re-emit.
BREAKING (behavioral): the default sub-orchestration instance ID FORMAT CHANGED. Code
that hard-coded or parsed the old `${parentId}:${hex4}` shape is affected. An explicitly
supplied options.instanceId is unaffected.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d86f5f69-4e1a-4c1d-b017-5e290c85cc05
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 22 out of 23 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
packages/azure-functions-durable/src/http/builtin.ts:165
- The lazy
require('@azure/identity')catch block will also treat any load-time failure (e.g., syntax error, transitive dependency issue) as “package missing”, which can mask real runtime problems and make troubleshooting much harder. It’s safer to only translateMODULE_NOT_FOUNDinto the install hint and rethrow other errors.
try {
identity = require("@azure/identity");
} catch {
throw new Error(
"callHttp with a tokenSource requires the optional '@azure/identity' package. " +
…ss continue-as-new Adds a DTS-emulator e2e test to orchestration.spec.ts (the spec path already listed in the dts-e2e-tests CI matrix, so it actually runs) for the default-derived sub-orchestration instance ID collision fixed in dbc5f47. The three tests added in dbc5f47 all run on the in-memory backend, which populates the per-execution executionId itself — so they prove the derivation formula but not its load-bearing premise: that the REAL DTS backend supplies an executionId (via OrchestratorRequest.executionId and/or ExecutionStarted.orchestrationInstance.executionId). If it does not, the fix silently falls back to the legacy colliding `${parentId}:${hex4}` format and every in-memory test still passes. This test closes that gap on the actual DTS emulator: a parent schedules a default-ID (no explicit instanceId) sub-orchestration, continue-as-news, then schedules another default-ID sub-orchestration, carrying generation 0's child instance ID forward via the continue-as-new input. It asserts the parent COMPLETES, both children ran their activity (childActivityRuns === 2), the two generations' child IDs differ, and — the premise check — each child ID is `${parentId}:${executionId}:${hex4}` (the remainder after the parent prefix contains a colon, i.e. an executionId segment), not the legacy 2-segment fallback. The child returns its own ctx.instanceId and the test logs both IDs so the real DTS-derived value is visible in CI. Pre-fix this fails with "Orchestration instance '<parentId>:0001' already exists" (or the parent never completes); if it fails post-fix because DTS does not supply executionId, that is a finding to escalate rather than paper over. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d86f5f69-4e1a-4c1d-b017-5e290c85cc05
Comment 3661095099 (builtin.ts, anchored to 6f9ced9) — the direct completion of the malformed-Location guard shipped in b8132e8. The 202 poll loop resolved any parseable `Location` and re-polled it. A callee-controlled `Location` with a non-http(s) scheme (e.g. `file:///etc/passwd`, `ftp://…`) parses cleanly through `new URL`, so the existing try/catch did NOT fire; execution reached `callActivity`, whose scheme guard then threw `callHttp only supports http/https URLs` and FAILED the whole orchestration instead of returning the first 202 for the caller to inspect. This is a robustness bug, not an exfiltration hole: the activity's scheme guard still blocks the `file://` fetch, so nothing is read. But a remote endpoint could kill the caller's orchestration with an opaque error. Fix it by treating a non-http(s) scheme exactly like a missing (`if (!location) break`) or unparseable Location: stop polling, return the 202 as-is. `URL.protocol` is WHATWG-normalized lowercase, so `FILE://`/`Ftp://` are covered; determinism holds since the check is pure computation over history-derived values. Tests (RED on the prior code, GREEN now): mirror the malformed-Location cases — `it.each(["file:///etc/passwd", "ftp://example.com/x"])` asserts the poll orchestrator does not throw, returns the first 202 unchanged, and schedules no further timer/activity (createTimer not called, callActivity called once). Compat unit 146 -> 148. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d86f5f69-4e1a-4c1d-b017-5e290c85cc05
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 22 out of 23 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
test/e2e-functions/test-app/src/functions/CallHttpOrchestration.ts:148
u.portis an empty string when the incoming request is on the scheme default port (80/443). In that case this template literal produces an invalid URL likehttps://localhost:/api/HttpCrossOriginStart, which will make the e2e starter fail outside the local dev port=7071 scenario. Build the URL viaURLmutation (or conditionally include the:${port}segment) to avoid emitting a trailing colon when the port is empty.
url: `${u.protocol}//${firstHost}:${u.port}/api/HttpCrossOriginStart`,
Round 11 (commit 4d2709c) already fixed the non-http(s) `Location` poll robustness bug for PR comment 3661095099; that commit crossed in flight with the reviewer's now-explicit spec. Align the two remaining deltas without changing behavior: - builtin.ts poll loop: narrow the `try` to wrap only `new URL(...)`, moving the http/https `protocol` check to after the catch. This is clearer about what can throw: only URL construction can, while the `URL.protocol` getter and `toString()` cannot. Consolidate the rationale into a single comment block covering both unusable cases (unparseable and non-http(s)). Behavior is identical: a parseable but non-http(s) Location still breaks the poll loop and returns the 202 for the caller to inspect. - http-builtin.spec.ts: add a NOTE to the non-http(s) test making explicit that `ctx.callActivity` is mocked, so the test does NOT reproduce the production orchestration failure (which originates from the activity's scheme guard that the mock bypasses). It asserts the unit-level cause instead: the poll loop breaks -- createTimer never called, callActivity called exactly once. No behavior change. compat unit 148, core unit 1170, lint/build clean. Derivation, CHANGELOG, and lockfile diffs remain EMPTY. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d86f5f69-4e1a-4c1d-b017-5e290c85cc05
…d-URL bug Two append-only test-fixture corrections, both raised by the PR reviewer bot against c79b77a: 1. test/e2e-azuremanaged/orchestration.spec.ts (comment 3661191997) -- comment only. Fix the "continue-as-news" -> "continues-as-new" typo and drop the circular self-referential "PR #333" from two comments, keeping the durable "issue #318" reference (git blame/merge metadata already record the PR). No it(...)/assertion/executionKeyedId regex touched. 2. test/e2e-functions/test-app/src/functions/CallHttpOrchestration.ts (comment 3661309482) -- a SECOND occurrence of the u.port invalid-URL bug already fixed at HttpCrossOriginStart (comment 3659318771, commit 6f9ced9). The ${u.protocol}//${host}:${u.port}/... template emits an invalid dangling ":" (e.g. https://127.0.0.1:/api/...) whenever the host binds a protocol-default port (80/443, where URL.port is ""). Rebuild the URL via new URL + hostname/ pathname/search mutation, which preserves the host's actual port and omits only scheme-default ports -- mirroring the HttpCrossOriginStart fix. Latent today (the Functions host binds 7071) but a real correctness bug in a fixture whose entire purpose is URL/origin handling; the line-89 fix had missed it. lint clean; test-app tsc --noEmit clean. Derivation, CHANGELOG, and lockfile diffs remain EMPTY. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d86f5f69-4e1a-4c1d-b017-5e290c85cc05
The comment on the DTS 100-char-limit nesting test claimed the derived child instance ID is "keyed on the child's OWN fresh executionId". That is false about how the code behaves. RuntimeOrchestrationContext. callSubOrchestrator derives the default child ID from `this._executionId` -- the SCHEDULING (parent) orchestration's per-generation execution ID (runtime-orchestration-context.ts:404-406) -- because the ID must be derived deterministically before the child exists, so only values the scheduler already holds can be used. A child-minted executionId cannot be in play. A wrong comment here is a live trap: a reader who believes a child value is used would conclude that swapping which executionId keys the ID is harmless, when in fact anything but the scheduler's replay-stable executionId breaks determinism -- in the one area of this codebase that has already produced two bugs (the cross-generation collision, then the 112-char length regression). The rewrite fixes the attribution and states why it must be the scheduler's, which is what prevents the misreading. Comment-only: no it(), assertion, or fixture setup changed, and the derivation source is untouched. Addresses review comment 3661384000. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d86f5f69-4e1a-4c1d-b017-5e290c85cc05
`builtin.ts` exported `__resetCredentialCacheForTests()` solely so the unit tests could clear the module-scope `cachedCredential` between cases. Shipping a test-only hook in production code is undesirable (per review feedback), so the export is deleted and the tests achieve the same isolation the way the nested "@azure/identity" suite already does: `jest.resetModules()` + re-`require` of the module before each test, which yields a fresh module instance with an empty credential cache. No production behavior changes -- `cachedCredential`, `getCredential()`, `acquireBearerToken()`, and the poll loop are untouched. Test changes (http-builtin.spec.ts): - Drop `builtinHttpActivity` and `__resetCredentialCacheForTests` from the static import; bind `builtinHttpActivity` per-test via a block-scoped `let` assigned in `beforeEach` from the freshly required module. - Replace the reset-hook `beforeEach` with `jest.resetModules()` + re-`require`, and document why (module-scope credential state -> re-require for an empty cache, deliberately avoiding a production reset export). Pure refactor: compat unit 148 and core unit 1170 both unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d86f5f69-4e1a-4c1d-b017-5e290c85cc05
andystaples
left a comment
There was a problem hiding this comment.
Requesting changes for two blocking issues and two compatibility gaps. The core replay, polling, managed-identity, and continue-as-new changes look sound; the inline comments identify the remaining issues that prevent calling the feature fully secure and v3-equivalent.
Addresses Andy Staples' review on #333 (callHttp restore). A2 (fix): `retryAfterSeconds` accepted arbitrarily large delays. A huge delta-seconds string (parseInt can even reach Infinity) OR an HTTP-date whose `Math.ceil` rounds one step past the maximum ECMAScript time value produced a delay for which `new Date(now + seconds*1000)` is Invalid; `createTimer` then throws on the NaN timestamp and fails the whole orchestration. Retry-After is a callee-controlled response header, so this was remote-triggerable. Restructure so a single guard covers both the delta-seconds and HTTP-date branches: clamp to the 30s default when `seconds` is non-finite or exceeds `floor((MAX_TIME_VALUE_MS - now)/1000)`. The bound is derived from the Date range (rejects the minimum necessary, so a large-but-in-range delay still passes) and also subsumes non-safe-integer values. Note the bug bites at 13 digits, before Infinity, so an isFinite()-only check is insufficient. Add 4 tests (Infinity, finite-out-of-range, HTTP-date ceil overflow, large-in-range passthrough); the 6 existing tests are unchanged. A3 (docs only): keep failing loudly on a GET/HEAD body, but relabel it a known incompatibility rather than a hardening choice. v3 and both sibling SDKs send the body regardless of method (verified .NET TaskHttpActivityShim and durabletask-python builtin.py have no method check); the Fetch Standard forbids it, so fetch-based callHttp cannot match without a different transport. A4 (docs only): stop describing callHttp as fully v3-compatible. `getHeader()` is unavailable on the plain-object response, so existing `response.getHeader(...)` calls fail at runtime and must index `response.headers[...]`. Reword the README headline, the DurableHttpResponse bullet, models.ts JSDoc, and add a pointer from the callHttp JSDoc to the README differences. No hydration is attempted. No production behavior change for A3/A4 (docs only); A2 changes only the out-of-range fallback. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d86f5f69-4e1a-4c1d-b017-5e290c85cc05
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 22 out of 23 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
packages/azure-functions-durable/src/http/models.ts:37
ManagedIdentityTokenSource.kindis currently a required property (because it’s declared asreadonly kind: string), which makesTokenSourcestructurally requirekind. However, the internal wire shape already treatskindas optional (DurableHttpRequestPayload.tokenSource.kind?: string), and the activity only usesresource. Makingkindoptional here improves v3-compatibility (callers can pass{ resource: ... }without having to construct the class or add akindfield) while preserving current runtime behavior.
export class ManagedIdentityTokenSource {
/** @hidden Discriminator matching the classic durable-functions v3 token-source shape. */
readonly kind: string = "AzureManagedIdentity";
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; 30s fallback, matching the classic host default), 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