Skip to content

feat(gax): support resumable uploads - #9287

Draft
feywind wants to merge 3 commits into
googleapis:mainfrom
feywind:resumable/gax
Draft

feat(gax): support resumable uploads#9287
feywind wants to merge 3 commits into
googleapis:mainfrom
feywind:resumable/gax

Conversation

@feywind

@feywind feywind commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Adds the client-side implementation of the resumable upload protocol to google-gax.

What's here

  • ResumableUploadDescriptor / ResumableUploadSession and the resumableUploadStub that generated clients wire into createApiCall
  • resumableSourceFromFile, a seekable upload source backed by a local file
  • CallOptions.resumableUpload, the transport context generated clients pass to the stub
  • exports from index, fallback and descriptor, plus user documentation in client-libraries.md
  • unit tests and a hermetic system test (real HTTP server, no credentials) covering the state machine, transient retries, recovery from state mismatches and resume from a saved session URL

onProgress callbacks may return void; the previous signature rejected the documented usage, which only logs progress.

Verification

  • npx tsc -p . — no errors in the touched files
  • npx mocha build/test/unit — 406 passing
  • npx mocha build/test/system-test/resumableUpload.js — 2 passing

Nothing in existing behaviour changes: the new session is only reachable through the new descriptor and stub.

Related to: #9283

Add the client-side implementation of the resumable upload protocol:

- ResumableUploadDescriptor and ResumableUploadSession, plus the
  resumableUploadStub that generated clients wire into createApiCall
- resumableSourceFromFile, a seekable source backed by a local file
- CallOptions.resumableUpload carrying the transport context that
  generated clients pass to the stub
- exports from index, fallback and descriptor, and client-libraries docs
- unit and hermetic system tests covering the state machine, transient
  retries, recovery from state mismatches and resume from a saved URL

The onProgress callback may return void; the documented usage logs
progress without returning a value.

@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 support for the resumable upload protocol in the Google API Extensions (gax) library, adding a state machine to manage chunked uploads, retries, and session recovery, along with corresponding tests and documentation. Feedback highlights a critical runtime crash in the HTTP request handling due to an incorrect response type cast, which also requires updating mock responses in unit and system tests. Additionally, the host/port parsing regex should be improved to support IPv6 addresses, and manual edits to the auto-generated markdown documentation should be moved upstream to the generator templates.

Comment on lines +1279 to +1294
const response = (await this.context.auth.request({
url,
method: 'POST',
headers,
body:
body === null || body === undefined
? undefined
: typeof body === 'string'
? body
: Buffer.from(body),
signal: controller.signal,
responseType: 'stream',
timeout: this.params?.timeout ?? DEFAULT_PER_REQUEST_TIMEOUT_MS,
validateStatus: () => true,
})) as unknown as Response;
const responseBody = Buffer.from(await response.arrayBuffer());

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.

critical

The this.context.auth.request method from google-auth-library returns a GaxiosResponse object, not a standard Response object. Casting it using as unknown as Response and calling response.arrayBuffer() will result in a runtime TypeError: response.arrayBuffer is not a function in production.

To fix this, change the responseType to 'arraybuffer' and read the buffered data directly from response.data instead of using 'stream' and trying to call .arrayBuffer().

Suggested change
const response = (await this.context.auth.request({
url,
method: 'POST',
headers,
body:
body === null || body === undefined
? undefined
: typeof body === 'string'
? body
: Buffer.from(body),
signal: controller.signal,
responseType: 'stream',
timeout: this.params?.timeout ?? DEFAULT_PER_REQUEST_TIMEOUT_MS,
validateStatus: () => true,
})) as unknown as Response;
const responseBody = Buffer.from(await response.arrayBuffer());
const response = await this.context.auth.request({
url,
method: 'POST',
headers,
body:
body === null || body === undefined
? undefined
: typeof body === 'string'
? body
: Buffer.from(body),
signal: controller.signal,
responseType: 'arraybuffer',
timeout: this.params?.timeout ?? DEFAULT_PER_REQUEST_TIMEOUT_MS,
validateStatus: () => true,
});
const responseBody = Buffer.from(response.data as ArrayBuffer);

Comment on lines +60 to +80
function resumableUploadResponse(
status: number,
headers: {[name: string]: string},
body = '',
): MockResponse {
const normalized: {[name: string]: string} = {};
for (const [name, value] of Object.entries(headers)) {
normalized[name.toLowerCase()] = value;
}
return {
status,
headers: {
get(name: string): string | null {
return normalized[name.toLowerCase()] ?? null;
},
},
async arrayBuffer(): Promise<ArrayBuffer> {
return Buffer.from(body) as unknown as ArrayBuffer;
},
};
}

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

Since the implementation is updated to use responseType: 'arraybuffer' and read from response.data, the mock response in the unit tests should be updated to return a GaxiosResponse-like object with a data property containing the buffer, rather than a standard Response with arrayBuffer().

function resumableUploadResponse(
  status: number,
  headers: {[name: string]: string},
  body = '',
): any {
  const normalized: {[name: string]: string} = {};
  for (const [name, value] of Object.entries(headers)) {
    normalized[name.toLowerCase()] = value;
  }
  return {
    status,
    headers: normalized,
    data: Buffer.from(body),
  };
}

Comment on lines +174 to +189
const fakeAuth = {
async request(opts: {
url?: string;
method?: string;
headers?: {[name: string]: string};
body?: string | Buffer;
signal?: AbortSignal;
}) {
return fetch(opts.url!, {
method: opts.method,
headers: opts.headers,
body: opts.body,
signal: opts.signal,
});
},
};

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

Since the implementation is updated to use responseType: 'arraybuffer' and read from response.data, the fakeAuth.request mock in the system tests should be updated to return a GaxiosResponse-like object with a data property containing the buffer, rather than a standard Response from fetch.

const fakeAuth = {
  async request(opts: {
    url?: string;
    method?: string;
    headers?: {[name: string]: string};
    body?: string | Buffer;
    signal?: AbortSignal;
  }) {
    const res = await fetch(opts.url!, {
      method: opts.method,
      headers: opts.headers,
      body: opts.body,
      signal: opts.signal,
    });
    const arrayBuffer = await res.arrayBuffer();
    return {
      status: res.status,
      headers: Object.fromEntries(res.headers.entries()),
      data: Buffer.from(arrayBuffer),
    };
  },
};

Comment on lines +687 to +691
const match = servicePath.match(/^(.*):(\d+)$/);
if (match) {
servicePath = match[1];
servicePort = parseInt(match[2], 10);
}

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 regex ^(.*):(\d+)$ will incorrectly match IPv6 addresses that do not have a port but end with digits (e.g., 2001:db8::1234), treating the last segment as a port. To correctly parse the host and port, especially when IPv6 addresses are involved, use a regex that handles bracketed IPv6 hosts.

Suggested change
const match = servicePath.match(/^(.*):(\d+)$/);
if (match) {
servicePath = match[1];
servicePort = parseInt(match[2], 10);
}
const match = servicePath.match(/^(\[[^\]]+\]|[^:]+):(\d+)$/);
if (match) {
servicePath = match[1];
servicePort = parseInt(match[2], 10);
}

Comment thread core/packages/gax/client-libraries.md Outdated
upload protocol. For these methods, the client method no
longer returns the response directly; it returns a
[`ResumableUpload`](https://googleapis.dev/nodejs/google-gax/latest/classes/ResumableUpload.html)
helper. Call `start()` with a `NodeJS.ReadableStream` and await `finished()`

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

Please do not manually edit auto-generated markdown files to make changes, as these edits will be overwritten during the next regeneration. Instead, apply the fix upstream in the generator or templates so that the documentation correctly states that the start() method takes a ResumableSource (via the uploadSource parameter) rather than a bare NodeJS.ReadableStream directly.

References
  1. Do not manually edit auto-generated files to fix typos or make other changes, as these edits will be overwritten during the next regeneration. Instead, apply the fixes upstream in the generator or templates.

Comment on lines +163 to +169
await helper.start({
uploadStream: dataStream,
chunkSize: 8 * 1024 * 1024, // 8MB chunks
onProgress: status => {
console.log(`Committed ${status.bytesUploaded} bytes to ${status.uploadUrl}`);
},
});

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

Please do not manually edit auto-generated markdown files to make changes, as these edits will be overwritten during the next regeneration. Instead, apply the fix upstream in the generator or templates so that the example correctly uses uploadSource (of type ResumableSource) rather than uploadStream to avoid runtime errors.

References
  1. Do not manually edit auto-generated files to fix typos or make other changes, as these edits will be overwritten during the next regeneration. Instead, apply the fixes upstream in the generator or templates.

Comment on lines +180 to +183
await helper.start({
uploadStream: dataStream,
resumeUrl: savedUploadUrl,
});

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

Please do not manually edit auto-generated markdown files to make changes, as these edits will be overwritten during the next regeneration. Instead, apply the fix upstream in the generator or templates so that the example correctly uses uploadSource (of type ResumableSource) rather than uploadStream to avoid runtime errors.

References
  1. Do not manually edit auto-generated files to fix typos or make other changes, as these edits will be overwritten during the next regeneration. Instead, apply the fixes upstream in the generator or templates.

CI type-checks this package with the repository root TypeScript
(^5.8.3, currently 5.9.3) rather than the 5.8.3 pinned here, which
tightens the Buffer generics:

- annotate the transmission buffer as Buffer instead of letting
  Buffer.alloc() narrow it to Buffer<ArrayBuffer>, which readNextChunk()
  and skipBytes() cannot assign to
- copy forwarded bodies into Uint8Array.from() in the system test, since
  fetch()'s BodyInit does not accept Buffer<ArrayBufferLike>
- merge the two 'fs' imports in resumableSourceFromFile.ts

Found by the monorepo linter (bin/linter.mjs) on googleapis#9287.
- The client-libraries.md examples passed `uploadStream: dataStream`, but
  the session takes `uploadSource` (a `ResumableSource`) and `dataStream`
  was never defined, so the snippets would not compile. Use
  `client.getResumableSource()` in both examples, and correct the helper
  name to `ResumableUploadSession`.
- `DEFAULT_UPLOAD_RATE_BYTES_PER_MS` is bytes per millisecond but held
  `5 * 1024 * 1024`, i.e. ~5 GiB/s rather than the documented ~5 MiB/s.
  Since `computeGlobalDeadlineMs` combines the scaled value with
  `Math.max` against the 10 minute default, size-based scaling could not
  engage below a ~3 TB payload. Express the documented rate per
  millisecond instead.
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