From 42d588249fed07ab08263f4be83bdec9e1928082 Mon Sep 17 00:00:00 2001 From: Thiyagu K Date: Wed, 6 May 2026 12:54:28 +0000 Subject: [PATCH 1/2] fix: hoist invocationId to ensure persistence across upload retries Hoists the generation of `persistentInvocationId` to the beginning of the upload process in `Bucket.upload` and `File.save`. This ensures that retried multipart upload attempts reuse the same invocation ID in the `x-goog-api-client` header, rather than generating a new one for each attempt. --- handwritten/storage/src/bucket.ts | 7 +- handwritten/storage/src/file.ts | 10 +- handwritten/storage/src/storage-transport.ts | 172 +++++++++++------- handwritten/storage/system-test/storage.ts | 36 ++++ handwritten/storage/test/bucket.ts | 47 +++++ handwritten/storage/test/file.ts | 76 ++++++-- handwritten/storage/test/headers.ts | 6 +- handwritten/storage/test/storage-transport.ts | 88 +++++++-- 8 files changed, 349 insertions(+), 93 deletions(-) diff --git a/handwritten/storage/src/bucket.ts b/handwritten/storage/src/bucket.ts index 62580e83bf5f..e44923b5e117 100644 --- a/handwritten/storage/src/bucket.ts +++ b/handwritten/storage/src/bucket.ts @@ -28,6 +28,7 @@ import * as http from 'http'; import * as path from 'path'; import {promisify} from 'util'; import AsyncRetry from 'async-retry'; +import {randomUUID} from 'crypto'; import {convertObjKeysToSnakeCase, handleContextValidation} from './util.js'; import {Acl, AclMetadata} from './acl.js'; @@ -4436,6 +4437,7 @@ class Bucket extends ServiceObject { optionsOrCallback?: UploadOptions | UploadCallback, callback?: UploadCallback, ): Promise | void { + const persistentInvocationId = randomUUID(); const upload = (numberOfRetries: number | undefined) => { const returnValue = AsyncRetry( async (bail: (err: GaxiosError | Error) => void) => { @@ -4446,7 +4448,10 @@ class Bucket extends ServiceObject { ) { newFile.storage.retryOptions.autoRetry = false; } - const writable = newFile.createWriteStream(options); + const writable = newFile.createWriteStream({ + ...options, + invocationId: persistentInvocationId, + }); if (options.onUploadProgress) { writable.on('progress', options.onUploadProgress); } diff --git a/handwritten/storage/src/file.ts b/handwritten/storage/src/file.ts index eb087581828f..59512dc3723f 100644 --- a/handwritten/storage/src/file.ts +++ b/handwritten/storage/src/file.ts @@ -27,6 +27,7 @@ import * as resumableUpload from './resumable-upload.js'; import {Writable, Readable, pipeline, Transform, PipelineSource} from 'stream'; import * as zlib from 'zlib'; import * as http from 'http'; +import {randomUUID} from 'crypto'; import { ExceptionMessages, @@ -248,6 +249,7 @@ export interface CreateResumableUploadOptions * @see {@link CRC32C.from} for possible values. */ resumeCRC32C?: Parameters<(typeof CRC32C)['from']>[0]; + invocationId?: string; preconditionOpts?: PreconditionOptions; [GCCL_GCS_CMD_KEY]?: resumableUpload.UploadConfig[typeof GCCL_GCS_CMD_KEY]; } @@ -4172,13 +4174,17 @@ class File extends ServiceObject { ) { maxRetries = 0; } + const persistentInvocationId = randomUUID(); const returnValue = AsyncRetry( async (bail: (err: Error) => void) => { return new Promise((resolve, reject) => { if (maxRetries === 0) { this.storage.retryOptions.autoRetry = false; } - const writable = this.createWriteStream(options); + const writable = this.createWriteStream({ + ...options, + invocationId: persistentInvocationId, + }); if (options.onUploadProgress) { writable.on('progress', options.onUploadProgress); @@ -4440,6 +4446,7 @@ class File extends ServiceObject { chunkSize: options?.chunkSize, highWaterMark: options?.highWaterMark, universeDomain: this.bucket.storage.universeDomain, + invocationId: options.invocationId, [GCCL_GCS_CMD_KEY]: options[GCCL_GCS_CMD_KEY], }; @@ -4499,6 +4506,7 @@ class File extends ServiceObject { uploadType: 'multipart', }, url, + invocationId: options.invocationId, [GCCL_GCS_CMD_KEY]: options[GCCL_GCS_CMD_KEY], method: 'POST', responseType: 'json', diff --git a/handwritten/storage/src/storage-transport.ts b/handwritten/storage/src/storage-transport.ts index 43070a73ff5e..2b123ba5f2e2 100644 --- a/handwritten/storage/src/storage-transport.ts +++ b/handwritten/storage/src/storage-transport.ts @@ -25,13 +25,13 @@ import { getModuleFormat, getRuntimeTrackingString, getUserAgentString, -} from './util'; +} from './util.js'; import {randomUUID} from 'crypto'; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore import {getPackageJSON} from './package-json-helper.cjs'; -import {GCCL_GCS_CMD_KEY} from './nodejs-common/util'; -import {RetryOptions} from './storage'; +import {GCCL_GCS_CMD_KEY} from './nodejs-common/util.js'; +import {RETRYABLE_ERR_FN_DEFAULT, RetryOptions} from './storage.js'; export interface StandardStorageQueryParams { alt?: 'json' | 'media'; @@ -49,6 +49,7 @@ export interface StorageQueryParameters extends StandardStorageQueryParams { export interface StorageRequestOptions extends GaxiosOptions { [GCCL_GCS_CMD_KEY]?: string; + invocationId?: string; interceptors?: GaxiosInterceptor[]; autoPaginate?: boolean; autoPaginateVal?: boolean; @@ -87,7 +88,6 @@ export interface StorageTransportCallback { fullResponse?: GaxiosResponse, ): void; } -let projectId: string; export class StorageTransport { authClient: GoogleAuth; @@ -113,7 +113,11 @@ export class StorageTransport { } this.providedUserAgent = options.userAgent; this.packageJson = getPackageJSON(); - this.retryOptions = options.retryOptions; + this.retryOptions = { + ...options.retryOptions, + retryableErrorFn: + options.retryOptions?.retryableErrorFn || RETRYABLE_ERR_FN_DEFAULT, + }; this.baseUrl = options.baseUrl; this.timeout = options.timeout; this.projectId = options.projectId; @@ -124,76 +128,109 @@ export class StorageTransport { reqOpts: StorageRequestOptions, callback?: StorageTransportCallback, ): Promise { - const headers = this.#buildRequestHeaders(reqOpts.headers); - if (reqOpts[GCCL_GCS_CMD_KEY]) { - headers.set( - 'x-goog-api-client', - `${headers.get('x-goog-api-client')} gccl-gcs-cmd/${reqOpts[GCCL_GCS_CMD_KEY]}`, - ); + const resolvedProjectId = + reqOpts.projectId || + this.projectId || + (await this.authClient.getProjectId()); + + if (!this.projectId) { + this.projectId = resolvedProjectId; } + + const queryParameters = { + project: resolvedProjectId, + ...reqOpts.queryParameters, + }; + + // Header Construction + const headers = this.#prepareHeaders(reqOpts); + + // Interceptor Management + this.gaxiosInstance.interceptors.request.clear(); if (reqOpts.interceptors) { - this.gaxiosInstance.interceptors.request.clear(); for (const inter of reqOpts.interceptors) { this.gaxiosInstance.interceptors.request.add(inter); } } - try { - const getProjectId = async () => { - if (reqOpts.projectId) return reqOpts.projectId; - projectId = await this.authClient.getProjectId(); - return projectId; - }; - const _projectId = await getProjectId(); - if (_projectId) { - projectId = _projectId; - this.projectId = projectId; - } + const urlString = reqOpts.url?.toString() || ''; + const isAbsolute = this.#isValidUrl(urlString); + + // Determine the base URL for the request + const requestUrl = isAbsolute + ? urlString + : new URL(urlString, this.baseUrl).toString(); + try { const requestPromise = this.authClient.request({ retryConfig: { retry: this.retryOptions.maxRetries, noResponseRetries: this.retryOptions.maxRetries, maxRetryDelay: this.retryOptions.maxRetryDelay, retryDelayMultiplier: this.retryOptions.retryDelayMultiplier, - shouldRetry: this.retryOptions.retryableErrorFn, totalTimeout: this.retryOptions.totalTimeout, + shouldRetry: err => !!this.retryOptions.retryableErrorFn?.(err), }, ...reqOpts, + data: reqOpts.body, + params: queryParameters, + paramsSerializer: this.#paramsSerializer, headers, - url: this.#buildUrl(reqOpts.url?.toString(), reqOpts.queryParameters), + url: requestUrl, timeout: this.timeout, + validateStatus: (status: number): boolean => { + const isResumable = !!( + reqOpts.queryParameters?.uploadType === 'resumable' || + reqOpts.url?.toString().includes('uploadType=resumable') + ); + return ( + (status >= 200 && status < 300) || (isResumable && status === 308) + ); + }, }); - return callback - ? requestPromise - .then(resp => callback(null, resp.data, resp)) - .catch(err => callback(err, null, err.response)) - : (requestPromise.then(resp => resp.data) as Promise); + // Response Handling + const responseHandler = (resp: GaxiosResponse) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const data = resp.data as any; + if (data !== null && typeof data === 'object') { + data.headers = resp.headers; + data.status = resp.status; + return data; + } + return resp; + }; + + if (callback) { + requestPromise + .then(resp => callback(null, responseHandler(resp), resp)) + .catch(err => callback(err, null, err.response)); + return; + } + + return requestPromise.then(responseHandler); } catch (e) { if (callback) return callback(e as GaxiosError); throw e; } } - #buildUrl(pathUri = '', queryParameters: StorageQueryParameters = {}): URL { - if ( - 'project' in queryParameters && - (queryParameters.project !== this.projectId || - queryParameters.project !== projectId) - ) { - queryParameters.project = this.projectId; - } - const qp = this.#buildRequestQueryParams(queryParameters); - let url: URL; - if (this.#isValidUrl(pathUri)) { - url = new URL(pathUri); - } else { - url = new URL(`${this.baseUrl}${pathUri}`); + #prepareHeaders(reqOpts: StorageRequestOptions): Record { + const headersObj = this.#buildRequestHeaders(reqOpts); + + if (reqOpts[GCCL_GCS_CMD_KEY]) { + const current = headersObj.get('x-goog-api-client') || ''; + headersObj.set( + 'x-goog-api-client', + `${current} gccl-gcs-cmd/${reqOpts[GCCL_GCS_CMD_KEY]}`, + ); } - url.search = qp; - return url; + const finalHeaders: Record = {}; + headersObj.forEach((v, k) => { + finalHeaders[k] = v; + }); + return finalHeaders; } #isValidUrl(url: string): boolean { @@ -204,32 +241,39 @@ export class StorageTransport { } } - #buildRequestHeaders(requestHeaders = {}) { - const headers = new Headers(requestHeaders); + /** + * Serializes query parameters into a string. + * Specifically handles arrays by appending each value individually + * to satisfy GCS "repeated key" requirements (e.g., for IAM permissions). + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + #paramsSerializer = (params: Record): string => { + const searchParams = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value === undefined) continue; + + if (Array.isArray(value)) { + value.forEach(v => searchParams.append(key, String(v))); + } else { + searchParams.set(key, String(value)); + } + } + return searchParams.toString(); + }; + #buildRequestHeaders(reqOpts: StorageRequestOptions) { + const headers = new Headers(reqOpts.headers); headers.set('User-Agent', this.#getUserAgentString()); + const invocationId = reqOpts.invocationId || randomUUID(); headers.set( 'x-goog-api-client', - `${getRuntimeTrackingString()} gccl/${this.packageJson.version}-${getModuleFormat()} gccl-invocation-id/${randomUUID()}`, + `${getRuntimeTrackingString()} gccl/${this.packageJson.version}-${getModuleFormat()} gccl-invocation-id/${invocationId}`, ); - return headers; } - #buildRequestQueryParams(queryParameters: StorageQueryParameters): string { - const qp = new URLSearchParams( - queryParameters as unknown as Record, - ); - - return qp.toString(); - } - #getUserAgentString(): string { - let userAgent = getUserAgentString(); - if (this.providedUserAgent) { - userAgent = `${this.providedUserAgent} ${userAgent}`; - } - - return userAgent; + const base = getUserAgentString(); + return this.providedUserAgent ? `${this.providedUserAgent} ${base}` : base; } } diff --git a/handwritten/storage/system-test/storage.ts b/handwritten/storage/system-test/storage.ts index c9b88c2ac0da..36d5324a84e7 100644 --- a/handwritten/storage/system-test/storage.ts +++ b/handwritten/storage/system-test/storage.ts @@ -3248,6 +3248,42 @@ describe('storage', function () { assert.strictEqual(called, true); }); + + it('should maintain the same invocationId across the upload lifecycle', async () => { + const invocationIds: string[] = []; + + const originalRequest = bucket.storageTransport.authClient.request.bind( + bucket.storageTransport.authClient, + ); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + bucket.storageTransport.authClient.request = async (config: any) => { + const headers = config.headers || {}; + const apiHeaderKey = Object.keys(headers).find( + key => key.toLowerCase() === 'x-goog-api-client', + ); + + if (apiHeaderKey) { + const val = headers[apiHeaderKey]; + const match = val.match(/gccl-invocation-id\/([a-f0-9-]+)/); + if (match) { + invocationIds.push(match[1]); + } + } + return originalRequest(config); + }; + + try { + const destination = `test-id-${Date.now()}.txt`; + await bucket.upload(FILES.big.path, {destination, resumable: false}); + + assert.ok(invocationIds.length >= 1); + const uniqueIds = [...new Set(invocationIds)]; + assert.strictEqual(uniqueIds.length, 1); + } finally { + bucket.storageTransport.authClient.request = originalRequest; + } + }); }); describe('channels', () => { diff --git a/handwritten/storage/test/bucket.ts b/handwritten/storage/test/bucket.ts index f89d3d3657f6..58fb7ffae681 100644 --- a/handwritten/storage/test/bucket.ts +++ b/handwritten/storage/test/bucket.ts @@ -2727,6 +2727,53 @@ describe('Bucket', () => { done(); }); }); + + it('should use the same invocationId across retries in a multipart upload', done => { + const fakeFile = new File(bucket, 'file-name'); + const options = { + destination: fakeFile, + resumable: false, + preconditionOpts: {ifGenerationMatch: 123}, + }; + let retryCount = 0; + let firstInvocationId: string | undefined; + + bucket.storage.retryOptions.autoRetry = true; + bucket.storage.retryOptions.maxRetries = 2; + bucket.storage.retryOptions.idempotencyStrategy = 1; + + fakeFile.createWriteStream = (options_: CreateWriteStreamOptions) => { + retryCount++; + const currentId = options_.invocationId; + + if (retryCount === 1) { + firstInvocationId = currentId; + } else { + assert.strictEqual(currentId, firstInvocationId); + } + + const ws = new stream.PassThrough(); + + setImmediate(() => { + if (retryCount === 1) { + const error = new Error('Retryable failure') as GaxiosError; + error.code = 500; + error.status = 500; + ws.emit('error', error); + } else { + ws.emit('metadata', {}); + ws.end(); + } + }); + return ws; + }; + + bucket.upload(filepath, options, err => { + assert.ifError(err); + assert.strictEqual(retryCount, 2); + done(); + }); + }); }); it('should allow overriding content type', done => { diff --git a/handwritten/storage/test/file.ts b/handwritten/storage/test/file.ts index 2adc16377083..c3e8f9cc3099 100644 --- a/handwritten/storage/test/file.ts +++ b/handwritten/storage/test/file.ts @@ -4555,26 +4555,29 @@ describe('File', () => { }); }); - it('should accept an options object', done => { - const options = {}; + it('should accept an options object', async () => { + const options = {resumable: false}; sandbox.stub(file, 'createWriteStream').callsFake(options_ => { - assert.strictEqual(options_, options); - setImmediate(done); - return new PassThrough(); + assert.strictEqual(options_?.resumable, options.resumable); + assert.ok(options_?.invocationId); + const ws = new PassThrough(); + setImmediate(() => ws.emit('finish')); + return ws; }); - file.save(DATA, options, assert.ifError); + await file.save(DATA, options, assert.ifError); }); - it('should not require options', done => { + it('should not require options', async () => { sandbox.stub(file, 'createWriteStream').callsFake(options_ => { - assert.deepStrictEqual(options_, {}); - setImmediate(done); - return new PassThrough(); + assert.ok(options_?.invocationId); + const ws = new PassThrough(); + setImmediate(() => ws.emit('finish')); + return ws; }); - file.save(DATA, assert.ifError); + await file.save(DATA, assert.ifError); }); it('should register the error listener', done => { @@ -4627,6 +4630,22 @@ describe('File', () => { file.save(DATA, assert.ifError); }); + + it('should generate a single invocationId and pass it to createWriteStream', async () => { + const options = {resumable: false}; + const createWriteStreamStub = sandbox + .stub(file, 'createWriteStream') + .callsFake(() => { + return new DelayedStreamNoError(); + }); + + await file.save(DATA, options); + + // Verify createWriteStream was called with an invocationId + const calledOptions = createWriteStreamStub.firstCall.args[0]; + assert.ok(calledOptions?.invocationId); + assert.strictEqual(typeof calledOptions?.invocationId, 'string'); + }); }); describe('setMetadata', () => { @@ -5191,6 +5210,22 @@ describe('File', () => { }); assert.strictEqual(file.storage.retryOptions.autoRetry, true); }); + + it('should pass the invocationId to the resumable upload configuration', done => { + const options = { + invocationId: 'resumable-persistent-id', + }; + + const resumableUpload = require('../src/resumable-upload'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(resumableUpload, 'upload').callsFake((cfg: any) => { + assert.strictEqual(cfg.invocationId, options.invocationId); + setImmediate(done); + return new PassThrough(); + }); + + file.startResumableUpload_(duplexify(), options); + }); }); }); @@ -5307,6 +5342,25 @@ describe('File', () => { await file.startSimpleUpload_(duplexify(), options); }); + it('should pass the invocationId to the storageTransport', async () => { + const options = { + invocationId: 'test-uuid-1234', + userProject: 'user-project-id', + }; + file.storageTransport.makeRequest = sandbox + .stub() + .callsFake((options_: StorageRequestOptions) => { + assert.strictEqual( + options_.queryParameters?.userProject, + options.userProject, + ); + assert.strictEqual(options_.invocationId, options.invocationId); + }) + .resolves({}); + + await file.startSimpleUpload_(duplexify(), options); + }); + describe('request', () => { describe('error', () => { const error = new Error('Error.'); diff --git a/handwritten/storage/test/headers.ts b/handwritten/storage/test/headers.ts index a3632ea85438..ebfa6e7963d8 100644 --- a/handwritten/storage/test/headers.ts +++ b/handwritten/storage/test/headers.ts @@ -78,7 +78,8 @@ describe('headers', () => { authClient.request = opts => { assert.ok( /^gl-node\/(?[^W]+) gccl\/(?[^W]+) gccl-invocation-id\/(?[^W]+)$/.test( - (opts.headers as Headers).get('x-goog-api-client')!, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (opts.headers as any)['x-goog-api-client']!, ), ); return Promise.resolve(gaxiosResponse); @@ -96,7 +97,8 @@ describe('headers', () => { authClient.request = opts => { assert.ok( /^gl-deno\/0.00.0 gccl\/(?[^W]+) gccl-invocation-id\/(?[^W]+)$/.test( - (opts.headers as Headers).get('x-goog-api-client')!, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (opts.headers as any)['x-goog-api-client']!, ), ); return Promise.resolve(gaxiosResponse); diff --git a/handwritten/storage/test/storage-transport.ts b/handwritten/storage/test/storage-transport.ts index 4b71c8fa9d66..b3c2e640df10 100644 --- a/handwritten/storage/test/storage-transport.ts +++ b/handwritten/storage/test/storage-transport.ts @@ -21,7 +21,7 @@ import {GoogleAuth} from 'google-auth-library'; import sinon from 'sinon'; import assert from 'assert'; import {GCCL_GCS_CMD_KEY} from '../src/nodejs-common/util'; -import {Gaxios} from 'gaxios'; +import {Gaxios, GaxiosResponse} from 'gaxios'; describe('Storage Transport', () => { let sandbox: sinon.SinonSandbox; @@ -64,20 +64,25 @@ describe('Storage Transport', () => { const reqOpts: StorageRequestOptions = { url: '/bucket/object', - queryParameters: {alt: 'json', userProject: 'user-project'}, + queryParameters: { + alt: 'json', + userProject: 'user-project', + }, headers: {'content-encoding': 'gzip'}, }; const _response = await transport.makeRequest(reqOpts); assert.strictEqual(requestStub.calledOnce, true); const calledWith = requestStub.getCall(0).args[0]; - assert.strictEqual( - calledWith.url.href, - `${baseUrl}/bucket/object?alt=json&userProject=user-project`, - ); - assert.strictEqual(calledWith.headers.get('content-encoding'), 'gzip'); + assert.strictEqual(calledWith.url, `${baseUrl}/bucket/object`); + assert.deepStrictEqual(calledWith.params, { + alt: 'json', + project: 'project-id', + userProject: 'user-project', + }); + assert.strictEqual(calledWith.headers['content-encoding'], 'gzip'); assert.ok( - calledWith.headers.get('User-Agent').includes('gcloud-node-storage/'), + calledWith.headers['user-agent'].includes('gcloud-node-storage/'), ); assert.deepStrictEqual(_response, response.data); }); @@ -113,14 +118,11 @@ describe('Storage Transport', () => { .args[0]; assert.ok( - calledWith.headers - .get('x-goog-api-client') - .includes('gccl-gcs-cmd/test-key'), + calledWith.headers['x-goog-api-client'].includes('gccl-gcs-cmd/test-key'), ); }); - // TODO: Undo this skip once the gaxios interceptor issue is resolved. - it.skip('should clear and add interceptors if provided', async () => { + it('should clear and add interceptors if provided', async () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const interceptorStub: any = sandbox.stub(); const reqOpts: StorageRequestOptions = { @@ -135,7 +137,18 @@ describe('Storage Transport', () => { transportInstance.interceptors.request.clear = clearStub; transportInstance.interceptors.request.add = addStub; - await transport.makeRequest(reqOpts); + const transportWithMock = new StorageTransport({ + apiEndpoint: baseUrl, + baseUrl, + authClient: authClientStub, + projectId: 'project-id', + retryOptions: transport['retryOptions'], + scopes: ['https://www.googleapis.com/auth/cloud-platform'], + packageJson: {name: 'test-package', version: '1.0.0'}, + gaxiosInstance: transportInstance, + }); + + await transportWithMock.makeRequest(reqOpts); assert.strictEqual(clearStub.calledOnce, true); assert.strictEqual(addStub.calledOnce, true); @@ -167,4 +180,51 @@ describe('Storage Transport', () => { const transport = new StorageTransport(options); assert.ok(transport.authClient instanceof GoogleAuth); }); + + it('should use the provided invocationId in x-goog-api-client header', async () => { + const invocationId = 'manual-id-5678'; + const mockResponse = { + config: {}, + data: {}, + headers: {}, + status: 200, + statusText: 'OK', + request: {}, + } as unknown as GaxiosResponse; + + const requestStub = transport.authClient.request as sinon.SinonStub; + requestStub.resolves(mockResponse); + + await transport.makeRequest({ + url: 'http://test', + invocationId: invocationId, + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const headers = requestStub.firstCall.args[0].headers as any; + const apiClientHeader = headers['x-goog-api-client']; + + assert.ok(apiClientHeader.includes(`gccl-invocation-id/${invocationId}`)); + }); + + it('should generate a new random ID if none is provided', async () => { + const mockResponse = { + config: {}, + data: {}, + headers: {}, + status: 200, + statusText: 'OK', + } as GaxiosResponse; + const requestStub = transport.authClient.request as sinon.SinonStub; + requestStub.resolves(mockResponse); + + await transport.makeRequest({url: 'http://test'}); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const headers = requestStub.firstCall.args[0].headers as any; + const apiClientHeader = headers['x-goog-api-client']; + + assert.ok(apiClientHeader.includes('gccl-invocation-id/')); + const id = apiClientHeader.split('gccl-invocation-id/')[1]; + assert.strictEqual(id.length, 36); + }); }); From 8791b33234685795cb0aae376edde051da422a9b Mon Sep 17 00:00:00 2001 From: Thiyagu K Date: Wed, 6 May 2026 13:23:07 +0000 Subject: [PATCH 2/2] fix: bug fix --- handwritten/storage/src/storage-transport.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/handwritten/storage/src/storage-transport.ts b/handwritten/storage/src/storage-transport.ts index 2b123ba5f2e2..30de7958eed6 100644 --- a/handwritten/storage/src/storage-transport.ts +++ b/handwritten/storage/src/storage-transport.ts @@ -172,7 +172,6 @@ export class StorageTransport { shouldRetry: err => !!this.retryOptions.retryableErrorFn?.(err), }, ...reqOpts, - data: reqOpts.body, params: queryParameters, paramsSerializer: this.#paramsSerializer, headers,