From ed564ff9e9a414a1d106438f81385a86ec70613f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Efe=20G=C3=B6kdemir?= Date: Tue, 3 Nov 2026 03:44:25 +0300 Subject: [PATCH 1/2] Retry GraphQL requests after throttling --- docs/usage/graphql.md | 3 + lib/shopify_api/clients/graphql/client.rb | 1 + lib/shopify_api/clients/http_client.rb | 57 +++++++++++++- lib/shopify_api/clients/http_request.rb | 1 + test/clients/graphql/admin_test.rb | 29 ++++++++ test/test_helpers/graphql_client.rb | 91 +++++++++++++++++++++++ 6 files changed, 181 insertions(+), 1 deletion(-) diff --git a/docs/usage/graphql.md b/docs/usage/graphql.md index e9e21162c..5b45453b3 100644 --- a/docs/usage/graphql.md +++ b/docs/usage/graphql.md @@ -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 diff --git a/lib/shopify_api/clients/graphql/client.rb b/lib/shopify_api/clients/graphql/client.rb index 3bf4d7798..0905fe7e1 100644 --- a/lib/shopify_api/clients/graphql/client.rb +++ b/lib/shopify_api/clients/graphql/client.rb @@ -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, ) diff --git a/lib/shopify_api/clients/http_client.rb b/lib/shopify_api/clients/http_client.rb index fe8809c84..24d41a9f3 100644 --- a/lib/shopify_api/clients/http_client.rb +++ b/lib/shopify_api/clients/http_client.rb @@ -1,6 +1,8 @@ # typed: strict # frozen_string_literal: true +require "time" + module ShopifyAPI module Clients class HttpClient @@ -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) @@ -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) @@ -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).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) } diff --git a/lib/shopify_api/clients/http_request.rb b/lib/shopify_api/clients/http_request.rb index 4b4ba47a4..efdfa3d59 100644 --- a/lib/shopify_api/clients/http_request.rb +++ b/lib/shopify_api/clients/http_request.rb @@ -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 diff --git a/test/clients/graphql/admin_test.rb b/test/clients/graphql/admin_test.rb index d04d92504..4aa2a12d2 100644 --- a/test/clients/graphql/admin_test.rb +++ b/test/clients/graphql/admin_test.rb @@ -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 diff --git a/test/test_helpers/graphql_client.rb b/test/test_helpers/graphql_client.rb index 783239575..658a659d7 100644 --- a/test/test_helpers/graphql_client.rb +++ b/test/test_helpers/graphql_client.rb @@ -54,6 +54,97 @@ 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_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 From f1fa7fdbfcd7c7b5d71fbdba6d7e19c2e087db53 Mon Sep 17 00:00:00 2001 From: Efe Date: Fri, 6 Nov 2026 14:43:52 +0300 Subject: [PATCH 2/2] Fix GraphQL throttle retry type checking Normalize the restore rate to Float, cover fractional rates in both GraphQL clients, and document bounded throttle retries in the changelog. Generated-by: dot --- CHANGELOG.md | 2 ++ lib/shopify_api/clients/http_client.rb | 2 +- test/test_helpers/graphql_client.rb | 29 ++++++++++++++++++++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 619b7ee29..ab931db5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. + - [#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). - [#1475](https://github.com/Shopify/shopify-api-ruby/pull/1475) Add `ShopifyAPI::Auth::ClientAssertion.create`, an optional helper that signs a client assertion with an `RS256` (RSA, 2048-bit minimum) or `ES256` (EC P-256) private key your app owns. `client_id` defaults to `ShopifyAPI::Context.api_key`, the only value the helper reads from the context, and it never generates, imports, stores, or rotates keys. - [#1475](https://github.com/Shopify/shopify-api-ruby/pull/1475) Add `ShopifyAPI::Errors::InvalidClientAssertionError`, raised for a blank or non-`String` assertion, for an assertion with surrounding whitespace, and for invalid signer input. diff --git a/lib/shopify_api/clients/http_client.rb b/lib/shopify_api/clients/http_client.rb index 24d41a9f3..c80462cc2 100644 --- a/lib/shopify_api/clients/http_client.rb +++ b/lib/shopify_api/clients/http_client.rb @@ -202,7 +202,7 @@ def graphql_throttle_retry_wait_time(body) return RETRY_WAIT_TIME end - [((requested_cost - available_cost).to_f / restore_rate).ceil, RETRY_WAIT_TIME].max + [((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 diff --git a/test/test_helpers/graphql_client.rb b/test/test_helpers/graphql_client.rb index 658a659d7..bbbcc2a0e 100644 --- a/test/test_helpers/graphql_client.rb +++ b/test/test_helpers/graphql_client.rb @@ -83,6 +83,35 @@ def test_retries_throttled_graphql_response_within_try_limit 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 = {