Skip to content

fix(gaxios): enforce maxContentLength on stream responses in Bun - #9777

Draft
danieljbruce wants to merge 2 commits into
fix/gaxios-bun-max-content-lengthfrom
fix/gaxios-bun-max-content-length-stream
Draft

danieljbruce wants to merge 2 commits into
fix/gaxios-bun-max-content-lengthfrom
fix/gaxios-bun-max-content-length-stream

Conversation

@danieljbruce

@danieljbruce danieljbruce commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

Description

Enforces maxContentLength on streaming (responseType: 'stream') responses under Bun.

Impact

Immediately aborts oversized response streams (res.body) under Bun when maxContentLength is exceeded.

Changes

  • Add getNodeStream() in Gaxios.#createBunFetch() to lazily convert the Web ReadableStream into a Node.js Readable and pipe it through a byte-counting Transform when maxSize > 0.
  • Return getNodeStream() from the res.body getter and readBuffer() so both responseType: 'stream' and buffered readers share the byte-limiting stream.

Stack

  1. fix(gaxios): enforce maxContentLength on headerless responses in Bun #9773 — fix(gaxios): enforce maxContentLength on headerless responses in Bun (fix/gaxios-bun-max-content-length → main)
  2. fix(gaxios): enforce maxContentLength on stream responses in Bun #9777 — fix(gaxios): enforce maxContentLength on stream responses in Bun (fix/gaxios-bun-max-content-length-stream → fix/gaxios-bun-max-content-length) (this PR)
  3. fix(gaxios): honor maxContentLength and maxRedirects instance defaults #9778 — fix(gaxios): honor maxContentLength and maxRedirects instance defaults (fix/gaxios-instance-defaults → fix/gaxios-bun-max-content-length-stream)
  4. fix(gaxios): forward https.Agent TLS options and agent factories in Bun #9774 — fix(gaxios): forward https.Agent TLS options and agent factories in Bun (fix/gaxios-bun-agent-tls → fix/gaxios-instance-defaults)

Testing

  • Added a responseType: 'stream' test in core/packages/gaxios/test/test.getch.ts verifying that consuming res.data rejects with over limit: 2 when maxContentLength is exceeded.
  • Steps to Reproduce (in terminal): Run cd core/packages/gaxios && pnpm run compile && JS_RUNTIME=bun MOCHA_PARALLEL=false bun --bun ../../../bin/run-test.cjs --no-c8 build/esm/test/test.getch.js -g "Web ReadableStream response body in Bun fetch path".

Alternatives

@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 introduces lazy conversion of the Web ReadableStream response body into a Node.js Readable stream, wrapping it in a byte-counting Transform stream when maxSize is specified to enforce maxContentLength. A corresponding test was also added. The review feedback highlights a potential crash when handling null or undefined response bodies (such as 204 or 304 responses) and suggests adding a guard. Additionally, it points out redundant stream destruction logic inside the transform method that can be simplified.

Comment on lines +825 to 871
const getNodeStream = () => {
// Lazily converts the Web ReadableStream response body into a Node.js
// Readable stream and wraps it in a byte-counting Transform when
// maxSize is set so both res.body and buffered body readers enforce
// maxContentLength.
if (!nodeStream) {
const webReadable = Readable.fromWeb(rawBody);
if (maxSize > 0) {
let totalBytes = 0;
const limiter = new Transform({
transform(chunk: Buffer | Uint8Array, _encoding, callback) {
totalBytes += chunk.byteLength;
if (totalBytes > maxSize) {
webReadable.destroy();
callback(
Object.assign(
new Error(
`content size at ${String((input as {url?: unknown})?.url ?? input)} over limit: ${maxSize}`,
),
{type: 'max-size'},
),
);
return;
}
callback(null, chunk);
},
});
webReadable.on('error', err => limiter.destroy(err));
limiter.once('close', () => {
if (!webReadable.destroyed) webReadable.destroy();
});
nodeStream = webReadable.pipe(limiter);
} else {
nodeStream = webReadable;
}
}
return nodeStream;
};
const readBuffer = async () => {
nodeStream ||= Readable.fromWeb(rawBody);
const chunks: Buffer[] = [];
let totalBytes = 0;
for await (const chunk of nodeStream) {
const buf = Buffer.isBuffer(chunk)
? chunk
: Buffer.from(chunk as Uint8Array);
totalBytes += buf.byteLength;
if (maxSize > 0 && totalBytes > maxSize) {
throw Object.assign(
new Error(
`content size at ${String((input as {url?: unknown})?.url ?? input)} over limit: ${maxSize}`,
),
{type: 'max-size'},
);
}
chunks.push(buf);
for await (const chunk of getNodeStream()) {
chunks.push(
Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array),
);
}
return Buffer.concat(chunks);
};

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.

high

1. Potential Crash on Null/Undefined Response Body

If the response body is null (e.g., for 204 No Content or 304 Not Modified responses), Readable.fromWeb(rawBody) will throw a TypeError because rawBody is null. We should add a guard to return null from getNodeStream() when rawBody is null, and handle this in readBuffer() to prevent runtime crashes.

2. Redundant Stream Destruction

Calling webReadable.destroy() directly inside the transform method is redundant because the limiter.once('close', ...) listener already handles destroying webReadable when the limiter stream closes. Removing it simplifies the logic and avoids potential race conditions.

        const getNodeStream = () => {
          // Lazily converts the Web ReadableStream response body into a Node.js
          // Readable stream and wraps it in a byte-counting Transform when
          // maxSize is set so both res.body and buffered body readers enforce
          // maxContentLength.
          if (!rawBody) {
            return null;
          }
          if (!nodeStream) {
            const webReadable = Readable.fromWeb(rawBody);
            if (maxSize > 0) {
              let totalBytes = 0;
              const limiter = new Transform({
                transform(chunk: Buffer | Uint8Array, _encoding, callback) {
                  totalBytes += chunk.byteLength;
                  if (totalBytes > maxSize) {
                    callback(
                      Object.assign(
                        new Error(
                          "content size at " + String((input as {url?: unknown})?.url ?? input) + " over limit: " + maxSize
                        ),
                        {type: "max-size"}
                      )
                    );
                    return;
                  }
                  callback(null, chunk);
                }
              });
              webReadable.on("error", err => limiter.destroy(err));
              limiter.once("close", () => {
                if (!webReadable.destroyed) webReadable.destroy();
              });
              nodeStream = webReadable.pipe(limiter);
            } else {
              nodeStream = webReadable;
            }
          }
          return nodeStream;
        };
        const readBuffer = async () => {
          const chunks: Buffer[] = [];
          const stream = getNodeStream();
          if (stream) {
            for await (const chunk of stream) {
              chunks.push(
                Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array)
              );
            }
          }
          return Buffer.concat(chunks);
        };
References
  1. Avoid overriding _destroy on instantiated Node.js streams for cleanup. Instead, use the standard public API stream.once('close', ...) for stream cleanup, as Node.js emits 'close' after every destroy() (whether due to early break, error, or normal end).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in dd7cbab: added a null guard (if (!rawBody) return null;) in getNodeStream(), guarded if (stream) in readBuffer(), removed the redundant webReadable.destroy() call inside transform(), and switched to pipeline(webReadable, limiter, () => {}) for stream error propagation and cleanup.

@danieljbruce

Copy link
Copy Markdown
Contributor Author

/gemini review

@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 refactors how response streams are handled in gaxios by introducing a lazy getNodeStream helper that wraps the Web ReadableStream in a byte-counting Transform stream when maxSize is specified. This ensures that maxContentLength is enforced for both buffered and streamed responses, and a corresponding test case is added. The review feedback points out a potential TypeError when rawBody is null (such as in 204 No Content or HEAD requests) and suggests using the pipeline utility from the stream module to simplify error propagation and stream cleanup.

Comment on lines +825 to 877
const getNodeStream = () => {
// Lazily converts the Web ReadableStream response body into a Node.js
// Readable stream and wraps it in a byte-counting Transform when
// maxSize is set so both res.body and buffered body readers enforce
// maxContentLength.
if (!nodeStream) {
const webReadable = Readable.fromWeb(rawBody);
if (maxSize > 0) {
let totalBytes = 0;
const limiter = new Transform({
transform(chunk: Buffer | Uint8Array, _encoding, callback) {
totalBytes += chunk.byteLength;
if (totalBytes > maxSize) {
webReadable.destroy();
callback(
Object.assign(
new Error(
`content size at ${String((input as {url?: unknown})?.url ?? input)} over limit: ${maxSize}`,
),
{type: 'max-size'},
),
);
return;
}
callback(null, chunk);
},
});
webReadable.on('error', err => limiter.destroy(err));
limiter.once('close', () => {
if (!webReadable.destroyed) webReadable.destroy();
});
nodeStream = webReadable.pipe(limiter);
} else {
nodeStream = webReadable;
}
}
return nodeStream;
};
const readBuffer = async () => {
nodeStream ||= Readable.fromWeb(rawBody);
const chunks: Buffer[] = [];
let totalBytes = 0;
for await (const chunk of nodeStream) {
const buf = Buffer.isBuffer(chunk)
? chunk
: Buffer.from(chunk as Uint8Array);
totalBytes += buf.byteLength;
if (maxSize > 0 && totalBytes > maxSize) {
throw Object.assign(
new Error(
`content size at ${String((input as {url?: unknown})?.url ?? input)} over limit: ${maxSize}`,
),
{type: 'max-size'},
);
}
chunks.push(buf);
for await (const chunk of getNodeStream()) {
chunks.push(
Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array),
);
}
return Buffer.concat(chunks);
};
Object.defineProperty(res, 'body', {
get() {
nodeStream ||= Readable.fromWeb(rawBody);
return nodeStream;
return getNodeStream();
},
configurable: true,
enumerable: true,

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.

high

If the response has no body (e.g., for a 204 No Content or HEAD request), rawBody will be null. Calling Readable.fromWeb(rawBody) directly will throw a TypeError under Bun, crashing the request.

Additionally, managing manual event listeners for error propagation and stream destruction can be error-prone and lead to resource leaks if one stream is destroyed but the other is not. We can simplify this and make it extremely robust by using pipeline from the stream module, which automatically handles bidirectional error propagation and stream cleanup.

        const getNodeStream = () => {
          // Lazily converts the Web ReadableStream response body into a Node.js
          // Readable stream and wraps it in a byte-counting Transform when
          // maxSize is set so both res.body and buffered body readers enforce
          // maxContentLength.
          if (!rawBody) {
            return null;
          }
          if (!nodeStream) {
            const webReadable = Readable.fromWeb(rawBody);
            if (maxSize > 0) {
              let totalBytes = 0;
              const limiter = new Transform({
                transform(chunk: Buffer | Uint8Array, _encoding, callback) {
                  totalBytes += chunk.byteLength;
                  if (totalBytes > maxSize) {
                    callback(
                      Object.assign(
                        new Error(
                          "content size at " + String((input as {url?: unknown})?.url ?? input) + " over limit: " + maxSize
                        ),
                        {type: "max-size"}
                      )
                    );
                    return;
                  }
                  callback(null, chunk);
                },
              });
              nodeStream = pipeline(webReadable, limiter, () => {});
            } else {
              nodeStream = webReadable;
            }
          }
          return nodeStream;
        };
        const readBuffer = async () => {
          const chunks: Buffer[] = [];
          const stream = getNodeStream();
          if (stream) {
            for await (const chunk of stream) {
              chunks.push(
                Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array)
              );
            }
          }
          return Buffer.concat(chunks);
        };
        Object.defineProperty(res, 'body', {
          get() {
            return getNodeStream();
          },
          configurable: true,
          enumerable: true,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in dd7cbab: added if (!rawBody) { return null; } in getNodeStream(), guarded if (stream) in readBuffer(), and replaced the manual stream listeners with nodeStream = pipeline(webReadable, limiter, () => {});.

@danieljbruce
danieljbruce added this pull request to stack #9782 October 29, 2026 20:51
} from './common.js';
import {getRetryConfig} from './retry.js';
import {PassThrough, Readable, pipeline} from 'stream';
import {PassThrough, Readable, Transform, pipeline} from 'stream';

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Generated] Import Transform from Node's built-in stream module so getNodeStream() can construct a byte-counting transform stream when maxContentLength (maxSize) is configured.

Comment on lines +825 to +860
const getNodeStream = () => {
// Lazily converts the Web ReadableStream response body into a Node.js
// Readable stream and wraps it in a byte-counting Transform when
// maxSize is set so both res.body and buffered body readers enforce
// maxContentLength.
if (!rawBody) {
return null;
}
if (!nodeStream) {
const webReadable = Readable.fromWeb(rawBody);
if (maxSize > 0) {
let totalBytes = 0;
const limiter = new Transform({
transform(chunk: Buffer | Uint8Array, _encoding, callback) {
totalBytes += chunk.byteLength;
if (totalBytes > maxSize) {
callback(
Object.assign(
new Error(
`content size at ${String((input as {url?: unknown})?.url ?? input)} over limit: ${maxSize}`,
),
{type: 'max-size'},
),
);
return;
}
callback(null, chunk);
},
});
nodeStream = pipeline(webReadable, limiter, () => {});
} else {
nodeStream = webReadable;
}
}
return nodeStream;
};

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Generated] Define getNodeStream() to lazily convert the Web ReadableStream (rawBody) into a Node.js Readable stream (returning null if rawBody is null/empty) and pipe it through a byte-counting Transform stream via pipeline(webReadable, limiter, () => {}) when maxSize > 0. This ensures both res.body stream consumers (responseType: 'stream') and buffered body readers enforce maxContentLength with automatic stream cleanup and error propagation.

return nodeStream;
};
const readBuffer = async () => {
nodeStream ||= Readable.fromWeb(rawBody);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Generated] Remove direct Readable.fromWeb(rawBody) initialization from readBuffer() since stream creation and maxContentLength enforcement are now centralized in getNodeStream().

Comment on lines +863 to +869
const stream = getNodeStream();
if (stream) {
for await (const chunk of stream) {
chunks.push(
Buffer.isBuffer(chunk)
? chunk
: Buffer.from(chunk as Uint8Array),

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Generated] Simplify readBuffer() to consume the stream returned by getNodeStream() (when non-null), delegating maxContentLength byte-limit checking to getNodeStream()'s Transform stream rather than duplicating byte counting in readBuffer().

Comment thread core/packages/gaxios/src/gaxios.ts Outdated
: Buffer.from(chunk as Uint8Array),
);
}
chunks.push(buf);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Generated] Consolidate chunk buffering inside the for await (const chunk of stream) loop now that byte-limit enforcement is handled upstream by getNodeStream().

get() {
nodeStream ||= Readable.fromWeb(rawBody);
return nodeStream;
return getNodeStream();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Generated] Route the res.body getter through getNodeStream() so responseType: 'stream' callers receive the maxContentLength-enforcing Node.js Readable stream under Bun.

Comment on lines 682 to +695
);

const streamMaxRes = await request<Readable>({
url,
maxContentLength: 2,
responseType: 'stream',
});
await assert.rejects(
(async () => {
for await (const chunk of streamMaxRes.data) {
assert.ok(chunk);
}
})(),
(err: Error) => /over limit: 2/.test(err.message),

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Generated] Add a unit test for responseType: 'stream' with maxContentLength: 2 on the Bun fetch code path (ReadableStream response body), verifying that iterating streamMaxRes.data rejects with over limit: 2 when the streamed payload exceeds maxContentLength.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant