Skip to content

[core] Support opt-in Expect: 100-continue in Node HTTP/1 transport - #40258

Open
Jeremy Meng (jeremymeng) wants to merge 21 commits into
Azure:mainfrom
jeremymeng:jeremymeng-100-continue-production
Open

Jeremy Meng (jeremymeng) wants to merge 21 commits into
Azure:mainfrom
jeremymeng:jeremymeng-100-continue-production

Conversation

@jeremymeng

@jeremymeng Jeremy Meng (jeremymeng) commented Oct 8, 2026 •

Copy link
Copy Markdown
Member

Copilot agent :copilot: (on behalf of Jeremy Meng (@jeremymeng)):

Packages impacted by this PR

  • @typespec/ts-http-runtime (existing unreleased 0.3.10).
  • @azure/core-rest-pipeline (tooling-managed patch increment to 1.25.1; runtime minimum updated to ^0.3.10). Publish the runtime before or together with the core package.

Issues associated with this PR

Fixes #40251.
Fixes #40387.

Describe the problem that is addressed by this PR

The built-in Node transport starts body factories, stream consumption, and upload progress before a server can decide whether to accept a potentially large upload. It also needs to preserve existing HTTP/1.1 streaming behavior, retries, redirects, authentication challenges, and caller-owned stream semantics when an early response arrives.

This PR adds opt-in Expect: 100-continue support when the caller explicitly supplies the header. It does not automatically add Expect or introduce a public option.

Changes in this PR:

  • Negotiation gate: sends headers first, then starts the body exactly once after HTTP 100 or a fixed 1,000 ms fallback. The fallback starts only after socket assignment and an ordered header-write callback, excluding agent queueing and DNS/TCP/TLS setup.
  • Effective headers and framing: honors replacement header objects and raw arrays, preserves automatic Host generation when omitted, and derives Content-Length or chunked framing before ClientRequest construction.
  • Lazy body production: defers body factories, multipart part factories, Blob conversion, source reads, and upload progress until the upload is permitted. Known-empty bodies end immediately without waiting.
  • Response/upload lifecycle: keeps early final responses readable; stops unfinished uploads for rejection, redirect, and challenge responses; preserves successful full-duplex 2xx uploads and explicit-length streams with asynchronous EOF; preserves original upload errors while the response remains open; retires sockets with interrupted outgoing framing.
  • Stream ownership and replay: leaves direct streams caller-owned, disposes factory-created sources and SDK wrappers, waits for .pipe() drain before resuming a multipart part or invoking the next factory, preserves readable-mode backpressure, and rejects every previously started, ended, closed, or disturbed one-shot source with REQUEST_BODY_NOT_REPLAYABLE.
  • Retry and policy integration: removes obsolete negotiation/framing on body-removing redirects, disposes abandoned streamed responses before retry/redirect/authentication resends on a best-effort basis, keeps legacy abort forwarding active until both upload and response completion, and preserves non-retryable errors for unsupported body types.
  • Documentation and package integration: documents the behavior in the runtime README and updates @azure/core-rest-pipeline to consume the new runtime version.

HTTP 417 is returned normally before or after upload starts; there is no automatic 417 recovery. This PR does not add HTTP/2/Undici support, a configurable continue timeout, or transport-specific behavior to Fetch/XHR/React Native.

What are the possible designs available to address the problem? If there are more than one possible design, why was the one in this PR chosen?

  • Public Node APIs instead of internals: the implementation uses header, socket, write-callback, and request events rather than TLS readiness heuristics or private ClientRequest state.
  • Deferred Expect header write: withholding Expect during construction and restoring it after agent header rewrites supports forward proxies and custom agents.
  • Ordered client-side flush boundary: an empty write callback after flushHeaders() provides a practical fallback boundary across fresh/reused connections, delayed DNS/TLS, HTTP forward proxies, and HTTPS CONNECT. It does not claim that the remote server has received the headers.
  • Private identity tracking instead of a public replay abstraction: sources are marked only when actually started. Untouched direct sources remain reusable after an early response; started one-shot sources require a factory that returns a fresh stream.
  • Ownership-aware cleanup: caller-provided streams are detached and paused, while request-owned factory results and wrappers may be destroyed. Successful full-duplex responses are allowed to finish their uploads.

Are there test cases added in this PR? (If not, why?)

Yes. Coverage includes:

  • Continue/fallback ordering, duplicate and late 100 responses, ignored 102/103 responses, timeout/abort/error/close races, and exactly-once body dispatch.
  • Effective object/flat/pair-array headers, automatic Host behavior, explicit and inferred framing, known-empty bodies, UTF-8, Buffer, ArrayBuffer, DataView, and typed-array windows.
  • Lazy multipart, Blob, FormData, and createFileFromStream factories; readable-mode backpressure; .pipe() pause/drain with single- and multi-chunk parts; direct/factory ownership; replay protection across negotiated and non-negotiated attempts.
  • Early rejection responses, active upload shutdown, readable streamed/compressed/HEAD responses, compressed-stream cancellation races, successful full-duplex responses, upload failures after successful response headers, explicit-length async EOF, keep-alive reuse, and interrupted-framing retirement.
  • Retries, redirects, authentication challenges, abandoned response disposal, legacy abort signals, unsupported body classification, forward proxies, HTTPS CONNECT, delayed lookup/TLS, and custom agents.

Tests use ephemeral loopback endpoints and existing trusted test certificates and require no Azure service. Linux/macOS and minimum-Node coverage remain part of CI. Already-flowing sources and bytes queued inside Node cannot be made lazy or unsent retroactively.

Defer upload sources until continue or the fixed flush-based fallback, preserve early responses, and retire incomplete outgoing framing. Integrate lazy multipart factories, one-shot replay protection, redirects, and streamed legacy cancellation.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: f15004e9-d812-46bf-abcf-2a9bb184fcae
@azure-pipelines

azure-pipelines Bot commented Oct 8, 2026 •

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 2 pipeline(s).
9 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

Use the absolute repository URL required by Verify Links.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: f15004e9-d812-46bf-abcf-2a9bb184fcae
@jeremymeng

Copy link
Copy Markdown
Member Author

/azp run js - storage-blob - tests

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).

@jeremymeng

Copy link
Copy Markdown
Member Author

/azp run js - storage-file-share - tests

@jeremymeng

Copy link
Copy Markdown
Member Author

/azp run js - storage-file-datalake - tests

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).

1 similar comment
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: feef5cbc-ea2d-48f7-916c-c481f4742476

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.

🟡 Changes recommended

Confirmed stream-lifecycle regressions block approval, and the shared transport changes warrant final human review.

2 open findings
What changed in this PR

Adds opt-in Node HTTP/1 upload negotiation to the shared runtime and integrates it with core-rest-pipeline.

Changes:

  • Defers uploads until HTTP 100 or a fixed fallback.
  • Adds lazy multipart handling, replay protection, and response cancellation cleanup.
  • Adds regression coverage, documentation, and coordinated package metadata updates.
File Description
sdk/​core/​ts-http-runtime/​test/​snippets.spec.ts Adds negotiation example.
sdk/​core/​ts-http-runtime/​test/​public/​node/​expectContinueState.spec.ts Tests negotiation state transitions.
sdk/​core/​ts-http-runtime/​test/​public/​node/​expectContinue.spec.ts Tests wire behavior and lifecycle.
sdk/​core/​ts-http-runtime/​test/​internal/​node/​httpServer.ts Adds loopback server and proxy helpers.
sdk/​core/​ts-http-runtime/​test/​internal/​node/​concat.spec.ts Tests lazy multipart streams.
sdk/​core/​ts-http-runtime/​test/​internal/​expectContinue.spec.ts Tests effective header handling.
sdk/​core/​ts-http-runtime/​src/​util/​nodeBody.ts Tracks stream ownership and replay.
sdk/​core/​ts-http-runtime/​src/​util/​expectContinue.ts Handles negotiation headers.
sdk/​core/​ts-http-runtime/​src/​util/​concat.ts Makes multipart consumption lazy.
sdk/​core/​ts-http-runtime/​src/​policies/​redirectPolicy.ts Clears negotiation on body removal.
sdk/​core/​ts-http-runtime/​src/​policies/​multipartPolicy.ts Preserves factory size metadata.
sdk/​core/​ts-http-runtime/​src/​nodeHttpClient.ts Implements negotiation and lifecycle handling.
sdk/​core/​ts-http-runtime/​README.md Documents negotiation behavior.
sdk/​core/​ts-http-runtime/​CHANGELOG.md Records runtime support.
sdk/​core/​core-rest-pipeline/​test/​public/​node/​expectContinue.spec.ts Tests core integration.
sdk/​core/​core-rest-pipeline/​src/​util/​file.ts Retains lazy file factories.
sdk/​core/​core-rest-pipeline/​src/​util/​abortSignalCleanup.ts Defers response-stream cleanup.
sdk/​core/​core-rest-pipeline/​src/​policies/​wrapAbortSignalLikePolicy.ts Extends legacy cancellation forwarding.
sdk/​core/​core-rest-pipeline/​src/​policies/​multipartPolicy.ts Passes lazy factories to runtime.
sdk/​core/​core-rest-pipeline/​src/​defaultHttpClient.ts Preserves streamed-response cancellation.
sdk/​core/​core-rest-pipeline/​src/​constants.ts Updates SDK version.
sdk/​core/​core-rest-pipeline/​README.md Documents integration behavior.
sdk/​core/​core-rest-pipeline/​package.json Updates version and runtime minimum.
sdk/​core/​core-rest-pipeline/​CHANGELOG.md Records integration changes.
pnpm-lock.yaml Updates dependency resolution metadata.
Files not reviewed (1)
  • pnpm-lock.yaml: Generated file

🧠 Review effort: Balanced

Comment thread sdk/core/ts-http-runtime/src/nodeHttpClient.ts
Comment thread sdk/core/ts-http-runtime/src/util/concat.ts
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: feef5cbc-ea2d-48f7-916c-c481f4742476
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: feef5cbc-ea2d-48f7-916c-c481f4742476
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: feef5cbc-ea2d-48f7-916c-c481f4742476
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: feef5cbc-ea2d-48f7-916c-c481f4742476
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: feef5cbc-ea2d-48f7-916c-c481f4742476
import { logger as coreLogger } from "../log.js";
import type { RestError } from "../restError.js";
import { isRestError } from "../restError.js";
import { disposeResponseBody } from "../util/responseBody.js";

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Copilot :copilot::  disposeResponseBody  is needed when a policy receives one response but decides to send another request instead of returning that response—retry, redirect, or authentication challenge.

With streamed responses, ignoring the old body leaves its connection occupied. For an early final response during an unfinished upload, the socket is intentionally retired only when the response body closes. If the policy neither reads nor cancels that body, a one-socket agent can deadlock: the replacement request waits forever for the previous socket.

So these policies dispose the abandoned response before resending:

• Retry: before another attempt.
• Redirect: before following  Location .
• Authentication: before resending after a challenge.

Disposal destroys/resumes Node streams or cancels browser streams. It is best-effort: cleanup failures are logged but must not replace the selected retry or resend. Responses returned to callers are not disposed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: feef5cbc-ea2d-48f7-916c-c481f4742476
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: feef5cbc-ea2d-48f7-916c-c481f4742476
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: feef5cbc-ea2d-48f7-916c-c481f4742476
@jeremymeng

Copy link
Copy Markdown
Member Author

/azp run js - storage-file-datalake - tests

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).

@jeremymeng

Copy link
Copy Markdown
Member Author

/azp run js - storage-file-share - tests

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).

@jeremymeng

Copy link
Copy Markdown
Member Author

/azp run js - storage-blob - tests

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: feef5cbc-ea2d-48f7-916c-c481f4742476
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: feef5cbc-ea2d-48f7-916c-c481f4742476
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: feef5cbc-ea2d-48f7-916c-c481f4742476
* Simplify Expect: 100-continue implementation

- Share disposeResponseBody from ts-http-runtime internal/util instead of duplicating it in core-rest-pipeline
- Extract header preparation from NodeHttpClient.makeRequest into prepareExpectContinueHeaders
- Deduplicate raw header pair normalization and body header stripping in expectContinue
- Add shared toError and toRequestError helpers

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1aa4c5c1-7005-4d8a-b7eb-2f63b8487ab2
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: f7f163d1-8382-4cbb-adc7-eeb36717d037
@jeremymeng

Copy link
Copy Markdown
Member Author

/azp run js - storage-blob - tests

@jeremymeng

Copy link
Copy Markdown
Member Author

/azp run js - storage-file-share - tests

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).

@jeremymeng

Copy link
Copy Markdown
Member Author

/azp run js - storage-file-datalake - tests

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).

1 similar comment
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).

@jeremymeng
Jeremy Meng (jeremymeng) marked this pull request as ready for review October 20, 2026 22:06
Copilot AI balanced review requested due to automatic review settings October 20, 2026 22:06

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.

🟡 Changes recommended

Legacy cancellation can disconnect during active uploads, and asynchronous response-disposal errors can escape handling.

2 open issues
2 resolved since last review

Review effort: Balanced

Comment on lines +47 to +51
if (readableStream.destroy) {
readableStream.destroy();
} else {
readableStream.resume();
}
Comment on lines +18 to +20
if (!stream || !stream.readable) {
cleanup();
return;
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 44d4b275-f5cc-4ad3-ada9-7ca9ad006dc6
Copilot AI balanced review requested due to automatic review settings October 20, 2026 23:58

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.

🟡 Changes recommended

Asynchronous response-disposal errors can escape error handling and terminate the process.

3 open issues

Review effort: Balanced

const readableStream: unknown = response.readableStreamBody;
if (isNodeResponseBody(readableStream)) {
if (readableStream.destroy) {
readableStream.destroy();

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants