Sitelet https://github.com/googleapis/google-api-ruby-client/pull/27338
Skip to content

feat(storage): setting idempotency token header - #27338

Open
shubhangi-google wants to merge 20 commits into
googleapis:mainfrom
shubhangi-google:add_idempotency_token_modified
Open

shubhangi-google wants to merge 20 commits into
googleapis:mainfrom
shubhangi-google:add_idempotency_token_modified

Conversation

@shubhangi-google

@shubhangi-google shubhangi-google commented Jul 17, 2026 •

Copy link
Copy Markdown
Contributor

This pull request introduces support for sending an idempotency token header (X-Goog-Gcs-Idempotency-Token) in API commands

@cpriti-os cpriti-os 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.

AI review: there are two issues.

  1. Global Default Scope Leak (Critical Issue):
    By setting RequestOptions.default.add_idempotency_token_header = true in google/apis/options.rb, this PR enables the GCS-specific X-Goog-Gcs-Idempotency-Token header globally for all 400+ Google API clients using google-apis-core (e.g., YouTube, Drive, Compute Engine, Calendar, Bigtable). This violates service abstraction boundaries and adds a 36-byte UUID overhead and confusing headers to every non-GCS request.
    Fix: Default add_idempotency_token_header to false in google-apis-core and explicitly enable it in the google-apis-storage_v1 client generation or configuration.

  2. Redundant Memoization:
    In set_idempotency_token_header, the @idempotency_token ||= SecureRandom.uuid memoization is functionally dead code because of the early return on the line right before it:

return if header.any? { |k, _| k.to_s.downcase == 'x-goog-gcs-idempotency-token' }
@idempotency_token ||= SecureRandom.uuid

Because the header Hash is mutated in place and retained across retries of the ApiCommand instance, the first network attempt injects the header into header. On subsequent retries, the loop evaluates return if header.any? { ... }, successfully finding the original key, and returning early. Therefore, @idempotency_token is never evaluated or used again across retries.
Fix: You can simply do header['X-Goog-Gcs-Idempotency-Token'] = SecureRandom.uuid, as caching the UUID as an instance variable is unnecessary.

@shubhangi-google
shubhangi-google marked this pull request as draft September 16, 2026 06:01
@shubhangi-google
shubhangi-google marked this pull request as ready for review September 18, 2026 06:54

def invocation_id_header
"gccl-invocation-id/#{SecureRandom.uuid}"
@invocation_id ||= SecureRandom.uuid

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.

Why are we making this an instance variable?

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.

without an instance variable, every retry would generate a brand new UUID. We use an instance variable to memoize the UUID.

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.

As discussed offline, please do the required testing.

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.

hi @bajajneha27 we already have test to check the Invocation ID across retries
https://github.com/googleapis/google-api-ruby-client/blob/main/google-apis-core/spec/google/apis/core/api_command_spec.rb#L249
we did the testing ,the functionality is working fine with instance variable

Comment thread google-apis-core/lib/google/apis/core/api_command.rb

def set_idempotency_token_header
return if options&.header&.any? { |k, _| k.to_s.downcase == 'x-goog-gcs-idempotency-token' }
return if header.any? { |k, _| k.to_s.downcase == 'x-goog-gcs-idempotency-token' }

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.

Can you please help me understand how header is populated?

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.

When a request is made for the first time, this hash doesn't have the idempotency token, so the code populates it with a SecureRandom.uuid.
However, if the API call fails (e.g., due to a 503 Service Unavailable) and the library automatically retries the request, the prepare! method is called again. During this retry, the internal header hash still contains the UUID that was generated on the first attempt. Above line detects this and returns early, ensuring the exact same UUID is sent again on the retry.

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.

My question is how we're populating header hash ?

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.

The header hash is an instance attribute (accessor for @Header) of the command object, which is inherited from the base HttpCommand class. It gets initialized (typically as an empty hash) when the command object is first instantiated.

Throughout the lifecycle of the request, the prepare! method and its helpers (like set_api_client_header, set_user_project_header, and set_idempotency_token_header) mutate this exact same instance variable. Because the library reuses the same command object instance during a retry, the header hash retains the values (like the UUID) that were populated into it during the initial request's prepare! call.

RequestOptions.default.quota_project = nil
RequestOptions.default.add_invocation_id_header = false
RequestOptions.default.upload_chunk_size = 100 * 1024 * 1024 # 100 MB
RequestOptions.default.add_idempotency_token_header = false

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.

Is adding idempotency token going to be optional?

I thought it was going to be added by default.

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.

as per this comment #27338 (review)
It has to be optional and default to false in core library, It is being enabled by default inside the Storage API client specifically.

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.

My question is, do we need this option at all?
Isn't the requirement to always keep the header?

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.

x-goog-gcs-idempotency-token is being used for storage operations specifically (as the header name contained x-goog-gcs) we don't need to keep it optional and we make it default as it will be ignored by other libraries (e.g., YouTube, Drive, Compute Engine, Calendar) which are using core library

Comment thread google-apis-core/spec/google/apis/core/api_command_spec.rb
Comment thread google-apis-core/spec/google/apis/core/api_command_spec.rb
command.options.header = { 'x-goog-gcs-idempotency-token' => 'my-custom-token' }
command.prepare!
expect(command.header['x-goog-gcs-idempotency-token']).to eql 'my-custom-token'
expect(command.header['X-Goog-Gcs-Idempotency-Token']).to be_nil

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.

Why would it be nil here?

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.

expect(command.header['x-goog-gcs-idempotency-token']).to eql 'my-custom-token'
Confirms that the user's custom lowercase header ('x-goog-gcs-idempotency-token') was preserved with the custom value.
expect(command.header['X-Goog-Gcs-Idempotency-Token']).to be_nil Confirms that the library didn't accidentally fall back to its default behavior and append a second, PascalCased header ('X-Goog-Gcs-Idempotency-Token'). If this were not nil, the final HTTP request would contain two conflicting idempotency headers, which could cause errors on the backend server.

@shubhangi-google

Copy link
Copy Markdown
Contributor Author

AI review: there are two issues.

  1. Global Default Scope Leak (Critical Issue):
    By setting RequestOptions.default.add_idempotency_token_header = true in google/apis/options.rb, this PR enables the GCS-specific X-Goog-Gcs-Idempotency-Token header globally for all 400+ Google API clients using google-apis-core (e.g., YouTube, Drive, Compute Engine, Calendar, Bigtable). This violates service abstraction boundaries and adds a 36-byte UUID overhead and confusing headers to every non-GCS request.
    Fix: Default add_idempotency_token_header to false in google-apis-core and explicitly enable it in the google-apis-storage_v1 client generation or configuration.
  2. Redundant Memoization:
    In set_idempotency_token_header, the @idempotency_token ||= SecureRandom.uuid memoization is functionally dead code because of the early return on the line right before it:
return if header.any? { |k, _| k.to_s.downcase == 'x-goog-gcs-idempotency-token' }
@idempotency_token ||= SecureRandom.uuid

Because the header Hash is mutated in place and retained across retries of the ApiCommand instance, the first network attempt injects the header into header. On subsequent retries, the loop evaluates return if header.any? { ... }, successfully finding the original key, and returning early. Therefore, @idempotency_token is never evaluated or used again across retries. Fix: You can simply do header['X-Goog-Gcs-Idempotency-Token'] = SecureRandom.uuid, as caching the UUID as an instance variable is unnecessary.

hi @cpriti-os x-goog-gcs-idempotency-token is being used for storage operations specifically (as the header name contained x-goog-gcs) we don't need to keep it optional and we make it default as it will be ignored by other libraries (e.g., YouTube, Drive, Compute Engine, Calendar) which are using core library

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.

3 participants