Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
Note: For changes to the API, see https://shopify.dev/changelog?filter=api
## Unreleased

- [#1478](https://github.com/Shopify/shopify-api-ruby/pull/1478) Retry throttled GraphQL responses within the configured attempt limit, using Shopify's reported cost budget or reset time. Cost-based waits support integer and fractional restore rates while passing Sorbet type checking. The default remains one attempt.

## 16.4.0 (2026-11-05)
- [#1481](https://github.com/Shopify/shopify-api-ruby/pull/1481) `ShopifyAPI::Auth::TokenExchange.exchange_token` now raises `ShopifyAPI::Errors::InvalidJwtTokenError` when token exchange rejects the session token with `invalid_identity_session`, matching the existing `invalid_subject_token` handling.
- [#1475](https://github.com/Shopify/shopify-api-ruby/pull/1475) Add an optional `client_assertion:` argument to `ShopifyAPI::Auth::ClientCredentials.client_credentials`. When supplied, the token request authenticates with a JWT client assertion and sends no client secret. Omitting it, or passing `nil`, keeps the existing client-secret behavior. See the [OAuth documentation](docs/usage/oauth.md#client-assertion-authentication).
Expand Down
3 changes: 3 additions & 0 deletions docs/usage/graphql.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ client = ShopifyAPI::Clients::Graphql::Admin.new(session: session, api_version:
```

### Making Authenticated API calls

`query` accepts `tries` to set the maximum number of HTTP requests (default `1`). When greater than `1`, the client retries `THROTTLED` GraphQL responses using Shopify's returned throttle budget or reset time. If the attempts are exhausted, the last response is returned.

#### Basic example

```ruby
Expand Down
1 change: 1 addition & 0 deletions lib/shopify_api/clients/graphql/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ def query(
extra_headers: headers,
body_type: "application/json",
tries: tries,
retry_on_graphql_throttled: true,
),
response_as_struct: response_as_struct || false,
)
Expand Down
57 changes: 56 additions & 1 deletion lib/shopify_api/clients/http_client.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# typed: strict
# frozen_string_literal: true

require "time"

module ShopifyAPI
module Clients
class HttpClient
Expand Down Expand Up @@ -85,6 +87,12 @@ def request(request, response_as_struct: false)
body = res.body
end

graphql_throttled = request.retry_on_graphql_throttled && graphql_throttled?(body)
graphql_retry_wait_time = T.let(nil, T.nilable(Integer))
if graphql_throttled && tries < request.tries
graphql_retry_wait_time = graphql_throttle_retry_wait_time(T.cast(body, T::Hash[String, T.untyped]))
end

if response_as_struct && body.is_a?(Hash)
json_body = body.to_json
body = JSON.parse(json_body, object_class: OpenStruct)
Expand All @@ -97,7 +105,13 @@ def request(request, response_as_struct: false)
Context.logger.warn("Deprecated request to Shopify API at #{request.path}, received reason: #{reason}")
end

break if response.ok?
if response.ok?
break unless graphql_throttled
break if tries == request.tries

sleep(T.must(graphql_retry_wait_time))
next
end

error_message = serialized_error(response)

Expand Down Expand Up @@ -150,6 +164,47 @@ def serialized_error(response)

private

sig { params(body: T.untyped).returns(T::Boolean) }
def graphql_throttled?(body)
return false unless body.is_a?(Hash)

errors = body["errors"]
return false unless errors.is_a?(Array)

errors.any? do |error|
error.is_a?(Hash) && error.dig("extensions", "code") == "THROTTLED"
end
end

sig { params(body: T::Hash[String, T.untyped]).returns(Integer) }
def graphql_throttle_retry_wait_time(body)
errors = body["errors"]
throttle_error = errors.find do |error|
error.is_a?(Hash) && error.dig("extensions", "code") == "THROTTLED"
end
reset_at = throttle_error&.dig("extensions", "cost", "windowResetAt")
if reset_at.is_a?(String)
begin
return [(Time.iso8601(reset_at) - Time.now).ceil, RETRY_WAIT_TIME].max
rescue ArgumentError
# Fall back to the shared GraphQL cost budget when the reset time is malformed.
end
end

cost = body.dig("extensions", "cost")
throttle_status = cost.is_a?(Hash) ? cost["throttleStatus"] : nil
requested_cost = cost.is_a?(Hash) ? cost["requestedQueryCost"] : nil
available_cost = throttle_status.is_a?(Hash) ? throttle_status["currentlyAvailable"] : nil
restore_rate = throttle_status.is_a?(Hash) ? throttle_status["restoreRate"] : nil

unless requested_cost.is_a?(Numeric) && available_cost.is_a?(Numeric) &&
restore_rate.is_a?(Numeric) && restore_rate.positive?
return RETRY_WAIT_TIME
end

[((requested_cost - available_cost).to_f / restore_rate.to_f).ceil, RETRY_WAIT_TIME].max
end

# A redirect on a credential-bearing request is the one error here a developer cannot diagnose from
# the status alone, so it says what happened, what was not sent, and what to check.
sig { params(res: HTTParty::Response).returns(String) }
Expand Down
1 change: 1 addition & 0 deletions lib/shopify_api/clients/http_request.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ class HttpRequest < T::Struct
prop :query, T.nilable(T::Hash[T.any(Symbol, String), T.untyped])
prop :extra_headers, T.nilable(T::Hash[T.any(Symbol, String), T.untyped])
prop :tries, Integer, default: 1
prop :retry_on_graphql_throttled, T::Boolean, default: false
# Unset leaves redirect handling to HTTParty, including any policy the app set in default_options.
prop :follow_redirects, T.nilable(T::Boolean)
# Sends this one request with HTTParty's inherited Basic and digest authentication turned off. Digest
Expand Down
29 changes: 29 additions & 0 deletions test/clients/graphql/admin_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,35 @@ def build_client
ShopifyAPI::Clients::Graphql::Admin.new(session: @session, api_version: @api_version)
end
end

def test_retries_shopifyql_throttle_after_window_reset
query = "{ shopifyqlQuery(query: \"FROM sales SHOW total_sales\") { tableData { columns { name } } } }"
now = Time.now
throttled_body = {
"errors" => [{
"message" => "Rate limited",
"extensions" => {
"code" => "THROTTLED",
"cost" => { "windowResetAt" => (now + 5).iso8601 },
},
}],
}
success_body = { "data" => { "shopifyqlQuery" => { "tableData" => { "columns" => [] } } } }
response_headers = { "content-type" => "application/json" }
url = "https://test-shop.myshopify.com/#{@path}/#{ShopifyAPI::Context.api_version}/graphql.json"
@client.instance_variable_get(:@http_client).expects(:sleep).with(5).once

stub_request(:post, url)
.with(body: { query: query, variables: nil }, headers: @expected_headers)
.to_return(body: throttled_body.to_json, headers: response_headers)
.times(1)
.then.to_return(body: success_body.to_json, headers: response_headers)

response = @client.query(query: query, tries: 2)

assert_equal(success_body, response.body)
assert_requested(:post, url, times: 2)
end
end
end
end
Expand Down
120 changes: 120 additions & 0 deletions test/test_helpers/graphql_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,126 @@ def test_can_make_query_with_variables
@client.query(query: query, variables: variables)
end

def test_retries_throttled_graphql_response_within_try_limit
query = "{ shop { name } }"
throttled_body = {
"errors" => [{ "message" => "Throttled", "extensions" => { "code" => "THROTTLED" } }],
"extensions" => {
"cost" => {
"requestedQueryCost" => 101,
"throttleStatus" => { "currentlyAvailable" => 1, "restoreRate" => 50 },
},
},
}
success_body = { "data" => { "shop" => { "name" => "Test shop" } } }
response_headers = { "content-type" => "application/json" }
url = "https://test-shop.myshopify.com/#{@path}/#{ShopifyAPI::Context.api_version}/graphql.json"

@client.instance_variable_get(:@http_client).expects(:sleep).with(2).once

stub_request(:post, url)
.with(body: { query: query, variables: nil }, headers: @expected_headers)
.to_return(body: throttled_body.to_json, headers: response_headers)
.times(1)
.then.to_return(body: success_body.to_json, headers: response_headers)

response = @client.query(query: query, tries: 2)

assert_equal(success_body, response.body)
assert_requested(:post, url, times: 2)
end

def test_retries_throttled_graphql_response_with_fractional_restore_rate
query = "{ shop { name } }"
throttled_body = {
"errors" => [{ "message" => "Throttled", "extensions" => { "code" => "THROTTLED" } }],
"extensions" => {
"cost" => {
"requestedQueryCost" => 10,
"throttleStatus" => { "currentlyAvailable" => 1, "restoreRate" => 2.5 },
},
},
}
success_body = { "data" => { "shop" => { "name" => "Test shop" } } }
response_headers = { "content-type" => "application/json" }
url = "https://test-shop.myshopify.com/#{@path}/#{ShopifyAPI::Context.api_version}/graphql.json"

@client.instance_variable_get(:@http_client).expects(:sleep).with(4).once

stub_request(:post, url)
.with(body: { query: query, variables: nil }, headers: @expected_headers)
.to_return(body: throttled_body.to_json, headers: response_headers)
.times(1)
.then.to_return(body: success_body.to_json, headers: response_headers)

response = @client.query(query: query, tries: 2)

assert_equal(success_body, response.body)
assert_requested(:post, url, times: 2)
end

def test_returns_throttled_graphql_response_without_retrying_by_default
query = "{ shop { name } }"
throttled_body = {
"errors" => [{ "message" => "Throttled", "extensions" => { "code" => "THROTTLED" } }],
}
response_headers = { "content-type" => "application/json" }
url = "https://test-shop.myshopify.com/#{@path}/#{ShopifyAPI::Context.api_version}/graphql.json"

stub_request(:post, url)
.with(body: { query: query, variables: nil }, headers: @expected_headers)
.to_return(body: throttled_body.to_json, headers: response_headers)

response = @client.query(query: query)

assert_equal(throttled_body, response.body)
assert_requested(:post, url, times: 1)
end

def test_does_not_retry_other_graphql_errors
query = "{ shop { name } }"
error_body = {
"errors" => [{ "message" => "Access denied", "extensions" => { "code" => "ACCESS_DENIED" } }],
}
response_headers = { "content-type" => "application/json" }
url = "https://test-shop.myshopify.com/#{@path}/#{ShopifyAPI::Context.api_version}/graphql.json"

stub_request(:post, url)
.with(body: { query: query, variables: nil }, headers: @expected_headers)
.to_return(body: error_body.to_json, headers: response_headers)

response = @client.query(query: query, tries: 2)

assert_equal(error_body, response.body)
assert_requested(:post, url, times: 1)
end

def test_returns_last_throttled_response_when_try_limit_is_exhausted
query = "{ shop { name } }"
throttled_body = {
"errors" => [{ "message" => "Throttled", "extensions" => { "code" => "THROTTLED" } }],
"extensions" => {
"cost" => {
"requestedQueryCost" => 101,
"throttleStatus" => { "currentlyAvailable" => 1, "restoreRate" => 50 },
},
},
}
response_headers = { "content-type" => "application/json" }
url = "https://test-shop.myshopify.com/#{@path}/#{ShopifyAPI::Context.api_version}/graphql.json"

@client.instance_variable_get(:@http_client).expects(:sleep).with(2).once

stub_request(:post, url)
.with(body: { query: query, variables: nil }, headers: @expected_headers)
.to_return(body: throttled_body.to_json, headers: response_headers)

response = @client.query(query: query, tries: 2)

assert_equal(throttled_body, response.body)
assert_requested(:post, url, times: 2)
end

def test_can_override_api_version
@api_version = "2022-01"
query = <<~QUERY
Expand Down
Loading