Skip to content

feat(durable-functions): restore worker-side callHttp (#318) - #333

Merged
YunchuWang merged 21 commits into
mainfrom
yunchuwang-restore-callhttp-v4
Jul 28, 2026
Merged

feat(durable-functions): restore worker-side callHttp (#318)#333
YunchuWang merged 21 commits into
mainfrom
yunchuwang-restore-callhttp-v4

Conversation

@YunchuWang

@YunchuWang YunchuWang commented Jul 24, 2026

Copy link
Copy Markdown
Member

What & why

Restores the classic durable-functions v3 context.df.callHttp API 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)

  • Built-in HTTP activity (BuiltIn__HttpActivity): performs a single fetch request and returns { statusCode, headers, content }. Non-2xx/202 responses are captured, not thrown (global fetch only rejects on network errors). Includes an SSRF guard rejecting any non-http/https scheme, and acquires a Managed Identity bearer token when a tokenSource is supplied.
  • Built-in poll orchestrator (BuiltIn__HttpPollOrchestrator): a core-native async function* that calls the activity and, while the response is 202 Accepted with a Location header, waits on a replay-safe durable timer (fireAt = currentUtcDateTime + delay, never Date.now()) honoring Retry-After (delta-seconds and HTTP-date forms; 30s fallback, matching the classic host default), resolves a relative Location against the current URI, and re-polls. Honors the v3 enablePolling opt-out.
  • callHttp(options): builds the request payload and schedules the poll orchestrator via callSubOrchestrator — a single yield, durable polling — returning Task<DurableHttpResponse>.
  • Auto-registration: both builtins register exactly once when the package is imported, so existing apps work with no changes.

v3 API restored

Re-exports the v3 public shapes so v3 imports resolve unchanged: DurableHttpRequest, DurableHttpResponse, CallHttpOptions, ManagedIdentityTokenSource, TokenSource. callHttp accepts the v3 CallHttpOptions (method, url, body, headers, tokenSource, enablePolling, deprecated asynchronousPatternEnabled alias), JSON-stringifies an object body, and resolves enablePolling = enablePolling ?? asynchronousPatternEnabled ?? true.

Note: ManagedIdentityTokenSource.kind is "AzureManagedIdentity" to match the actual v3 source shape (the local azure-functions-durable-js clone), rather than "ManagedIdentity". Only resource is functionally significant.

⚠️ Trust-boundary change (v3 → v4)

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/identity DefaultAzureCredential, loaded via a lazy require only when a tokenSource is present (scope = resource.replace(/\/+$/,'') + '/.default'; sends the real Bearer <token>). It is added as an optional dependency; if a tokenSource is 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

  • Built-in activity: 200 passthrough; missing uri throws; non-http/https scheme throws; tokenSource sets a real Authorization: Bearer <token> (credential mocked; asserts the real token); 202 captured (not thrown).
  • Poll orchestrator: single 200 returns immediately (no timer); 202+Location yields activity → timer → re-poll; Retry-After delta-seconds and HTTP-date parsing; relative Location resolution; enablePolling=false returns the first 202 without looping.
  • callHttp wiring: schedules the sub-orchestration under BUILTIN_HTTP_POLL_ORCHESTRATOR_NAME with the built payload; object body → JSON.stringify; default enablePolling true. Updated the existing orchestration-context.spec.ts test that previously asserted callHttp throws.
  • Auto-registration: both builtins are registered via the app registration path.

Gates

  • npm run build:core — exit 0
  • npm run build -w durable-functions — exit 0
  • npm run lint — exit 0
  • durable-functions unit tests — 125 passed / 17 suites
  • core unit tests — 1163 passed / 66 suites

Credit: ported from durabletask-python#155 (Andy Staples). CHANGELOG intentionally left unchanged.

Fixes #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<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
Copilot AI review requested due to automatic review settings July 24, 2026 16:52

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 via callSubOrchestrator.
  • 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/identity as 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.

Comment thread packages/azure-functions-durable/src/http/builtin.ts
Comment thread packages/azure-functions-durable/src/http/builtin.ts
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
Copilot AI review requested due to automatic review settings July 24, 2026 23:24

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-supplied authorization (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) {

Comment thread packages/azure-functions-durable/README.md
Comment thread packages/azure-functions-durable/package.json Outdated
Comment thread packages/azure-functions-durable/src/orchestration-context.ts
…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
Copilot AI review requested due to automatic review settings July 27, 2026 17:01

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 12 out of 13 changed files in this pull request and generated 3 comments.

Comment thread test/e2e-functions/test-app/src/functions/CallHttpOrchestration.ts Outdated
Comment thread packages/azure-functions-durable/src/http/builtin.ts Outdated
Comment thread packages/azure-functions-durable/src/orchestration-context.ts
… 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
Copilot AI review requested due to automatic review settings July 27, 2026 17:17

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • location is built using ${u.protocol}//localhost:${u.port}/.... If request.url has no explicit port (default 80/443), u.port is an empty string and this produces an invalid URL like https://localhost:/api/HttpAuthEcho (extra colon).
    const u = new URL(request.url);
    const location = `${u.protocol}//localhost:${u.port}/api/HttpAuthEcho`;

Comment thread packages/azure-functions-durable/src/http/builtin.ts
…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
Copilot AI review requested due to automatic review settings July 27, 2026 17:38

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread packages/azure-functions-durable/src/http/models.ts
…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
Copilot AI review requested due to automatic review settings July 27, 2026 19:31

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 translate MODULE_NOT_FOUND into 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
Copilot AI review requested due to automatic review settings July 27, 2026 19:49
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
Copilot AI review requested due to automatic review settings July 27, 2026 22:00

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.port is 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 like https://localhost:/api/HttpCrossOriginStart, which will make the e2e starter fail outside the local dev port=7071 scenario. Build the URL via URL mutation (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
Copilot AI review requested due to automatic review settings July 27, 2026 22:16

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 22 out of 23 changed files in this pull request and generated 1 comment.

Comment thread test/e2e-functions/test-app/src/functions/CallHttpOrchestration.ts Outdated
…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
Copilot AI review requested due to automatic review settings July 27, 2026 22:29

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 22 out of 23 changed files in this pull request and generated 1 comment.

Comment thread packages/durabletask-js/test/sub-orchestration-instance-id.spec.ts Outdated
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
Copilot AI review requested due to automatic review settings July 27, 2026 22:50

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 22 out of 23 changed files in this pull request and generated 1 comment.

Comment thread packages/durabletask-js/src/testing/in-memory-backend.ts
`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
Copilot AI review requested due to automatic review settings July 28, 2026 16:17

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 22 out of 23 changed files in this pull request and generated no new comments.

@andystaples andystaples left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/azure-functions-durable/src/http/builtin.ts
Comment thread packages/azure-functions-durable/src/http/builtin.ts Outdated
Comment thread packages/azure-functions-durable/src/http/builtin.ts
Comment thread packages/azure-functions-durable/src/http/builtin.ts
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
Copilot AI review requested due to automatic review settings July 28, 2026 20:39

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.kind is currently a required property (because it’s declared as readonly kind: string), which makes TokenSource structurally require kind. However, the internal wire shape already treats kind as optional (DurableHttpRequestPayload.tokenSource.kind?: string), and the activity only uses resource. Making kind optional here improves v3-compatibility (callers can pass { resource: ... } without having to construct the class or add a kind field) while preserving current runtime behavior.
export class ManagedIdentityTokenSource {
  /** @hidden Discriminator matching the classic durable-functions v3 token-source shape. */
  readonly kind: string = "AzureManagedIdentity";

@YunchuWang
YunchuWang requested a review from andystaples July 28, 2026 20:53
@YunchuWang
YunchuWang dismissed andystaples’s stale review July 28, 2026 20:56

requesting review again

@YunchuWang
YunchuWang merged commit a25e445 into main Jul 28, 2026
30 checks passed
@YunchuWang
YunchuWang deleted the yunchuwang-restore-callhttp-v4 branch July 28, 2026 21:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[durable-functions v4] Investigate restoring context.df.callHttp as a worker-side durable activity

3 participants