Skip to content

fix(teeny-request): destroy response stream if request stream is destroyed early - #9160

Open
westarle wants to merge 1 commit into
googleapis:mainfrom
westarle:issue-8670-fix
Open

fix(teeny-request): destroy response stream if request stream is destroyed early#9160
westarle wants to merge 1 commit into
googleapis:mainfrom
westarle:issue-8670-fix

Conversation

@westarle

@westarle westarle commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

If the consumer destroys the returned stream before piping starts, teeny-request should skip pipeline construction and destroy the unused response body. This prevents unhandled rejections (e.g. ERR_STREAM_UNABLE_TO_PIPE) when trying to pipe to a closed/destroyed stream.

Fixes #8670

@gemini-code-assist gemini-code-assist Bot 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.

Code Review

This pull request prevents piping streams if the request stream is destroyed early, and ensures the response stream is properly destroyed instead. It also adds a test case to verify this behavior. The review feedback suggests simplifying a redundant null-check on responseStream and refactoring the test to avoid using setTimeout, which can cause flakiness in CI/CD environments.

Comment on lines +281 to +285
if (!requestStream.destroyed) {
pipeline(responseStream, requestStream, () => {});
} else if (responseStream && typeof responseStream.destroy === 'function') {
responseStream.destroy();
}

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.

medium

The check responseStream && is redundant here because this block is already nested inside if (responseStream) on line 280. We can safely simplify this condition.

        if (!requestStream.destroyed) {
          pipeline(responseStream, requestStream, () => {});
        } else if (typeof responseStream.destroy === 'function') {
          responseStream.destroy();
        }

Comment on lines +301 to +324
it('should not pipe if request stream is destroyed early', done => {
const scope = mockJson();
let bodyDestroyed = false;

const stream = teenyRequest({uri});
stream.on('error', done);

stream.once('response', response => {
response.body.once('close', () => {
bodyDestroyed = true;
});

// Destroy the stream before piping can be fully set up
stream.destroy();

setTimeout(() => {
assert.strictEqual(bodyDestroyed, true);
scope.done();
done();
}, 50);
});

stream.resume();
});

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.

medium

Using an arbitrary setTimeout in asynchronous tests can lead to flakiness in slower CI/CD environments. Since we expect the stream to be destroyed, we can listen directly to the close event of the response body to resolve the test, which is more robust and faster.

  it('should not pipe if request stream is destroyed early', done => {
    const scope = mockJson();

    const stream = teenyRequest({uri});
    stream.on('error', done);

    stream.once('response', response => {
      response.body.once('close', () => {
        scope.done();
        done();
      });

      // Destroy the stream before piping can be fully set up
      stream.destroy();
    });

    stream.resume();
  });

…royed early

If the consumer destroys the returned stream before piping starts,
teeny-request should skip pipeline construction and destroy the unused
response body. This prevents unhandled rejections (e.g. ERR_STREAM_UNABLE_TO_PIPE)
when trying to pipe to a closed/destroyed stream.

Fixes: 8670
@dich-bandana

Copy link
Copy Markdown

We independently confirmed this race in a production Cloud Run service using @google-cloud/storage@7.18.0 -> teeny-request@9.0.0 on Node 24. The rejected promise reached the service's process-level unhandledRejection handler, which logged the failure and exited the instance:

Error [ERR_STREAM_UNABLE_TO_PIPE]: Cannot pipe to a closed or destroyed stream
    at pipelineImpl (node:internal/streams/pipeline:264:15)
    at pipeline (node:internal/streams/pipeline:183:10)
    at PassThrough.<anonymous> (.../build/server.js:145022:37)

Our trace and validation:

  • The bundled stack location maps directly to teeny-request's deferred pipeline(responseStream, requestStream) call.
  • A deterministic reproduction delays the HTTP response, destroys the returned request stream, and then releases the response.
  • Using the unmodified teeny-request@9.0.0, Node 22.16.0 exits cleanly while Node 24.15.0 reproduces the exact ERR_STREAM_UNABLE_TO_PIPE uncaught/unhandled failure.
  • Matching production errors predate the downstream application's recent stream changes, ruling those changes out as the introduction point.
  • We prepared a narrow downstream patch with the same destroyed-stream guard and response-body cleanup: workwisecare/expert-octo#6178.

We evaluated rolling the service back to Node 22, forcing teeny-request@8.0.2, and replacing the Storage client with custom authenticated HTTP calls. The first only masks the latent race, the second is outside Storage's supported dependency range and reverts intentional cleanup behavior, and the third is a much larger rewrite. This PR is the preferred upstream resolution; once it is released through teeny-request@11 and consumed by @google-cloud/storage@8, we can upgrade and remove the downstream patch.

Thanks for picking this up. The implementation here matches the fix we independently validated, and the passing Node 22/24/26 regression coverage addresses the runtime boundary we observed.

@dich-bandana dich-bandana 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.

The implementation and regression test match the production race we reproduced and look good to me. Fixes: 8670 is not linking the issue; change it to Fixes #8670. Is anything else keeping this in draft, or would help getting it ready for review be useful?

@westarle
westarle marked this pull request as ready for review August 24, 2026 18:24
@westarle
westarle requested a review from a team as a code owner August 24, 2026 18: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.

teeny-request throws when response handler destroys the request stream

2 participants