diff --git a/CHANGELOG.md b/CHANGELOG.md index 56f20cf2f..8889be6d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ Note: For changes to the API, see https://shopify.dev/changelog?filter=api ## Unreleased +- [#1467](https://github.com/Shopify/shopify-api-ruby/pull/1467) Add optional `open_timeout` and `read_timeout` to `ShopifyAPI::Context.setup`, applied to every API request. Unset, requests keep Net::HTTP's defaults. ## 16.3.0 (2026-08-04) - [#1443](https://github.com/Shopify/shopify-api-ruby/pull/1443) Add `ShopifyAPI::Utils::ShopValidator` with `sanitize_shop_domain` and `sanitize!`. diff --git a/README.md b/README.md index 8449a2654..ee2816ff2 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,18 @@ ShopifyAPI::Context.setup( ) ``` +By default, API requests use Net::HTTP's timeouts (60 seconds per read). To fail faster when Shopify is slow to respond, set `open_timeout` and/or `read_timeout` in seconds: + +```ruby +ShopifyAPI::Context.setup( + # other params... + open_timeout: 5, + read_timeout: 10, +) +``` + +A request that times out raises `Net::OpenTimeout` or `Net::ReadTimeout`. It is not retried. + ### Performing OAuth You need to go through OAuth as described [here](https://shopify.dev/docs/apps/auth/oauth) to create sessions for shops using your app. diff --git a/lib/shopify_api/clients/http_client.rb b/lib/shopify_api/clients/http_client.rb index 6aeff5050..e5ba516c9 100644 --- a/lib/shopify_api/clients/http_client.rb +++ b/lib/shopify_api/clients/http_client.rb @@ -44,6 +44,8 @@ def request(request, response_as_struct: false) headers = append_first_party_development_headers(headers, parsed_uri) + timeouts = { open_timeout: Context.open_timeout, read_timeout: Context.read_timeout }.compact + tries = 0 response = HttpResponse.new(code: 0, headers: {}, body: "") while tries < request.tries @@ -54,6 +56,7 @@ def request(request, response_as_struct: false) headers: headers, query: request.query, body: request.body.class == Hash ? T.unsafe(request.body).to_json : request.body, + **timeouts, ), HTTParty::Response) begin diff --git a/lib/shopify_api/context.rb b/lib/shopify_api/context.rb index 19a18bfa9..518850b43 100644 --- a/lib/shopify_api/context.rb +++ b/lib/shopify_api/context.rb @@ -26,6 +26,8 @@ class Context @rest_resource_loader = T.let(nil, T.nilable(Zeitwerk::Loader)) @expiring_offline_access_tokens = T.let(false, T::Boolean) + @open_timeout = T.let(nil, T.nilable(T.any(Integer, Float))) + @read_timeout = T.let(nil, T.nilable(T.any(Integer, Float))) class << self extend T::Sig @@ -49,6 +51,8 @@ class << self response_as_struct: T.nilable(T::Boolean), rest_disabled: T.nilable(T::Boolean), expiring_offline_access_tokens: T.nilable(T::Boolean), + open_timeout: T.nilable(T.any(Integer, Float)), + read_timeout: T.nilable(T.any(Integer, Float)), ).void end def setup( @@ -68,7 +72,9 @@ def setup( api_host: nil, response_as_struct: false, rest_disabled: false, - expiring_offline_access_tokens: false + expiring_offline_access_tokens: false, + open_timeout: nil, + read_timeout: nil ) unless ShopifyAPI::AdminVersions::SUPPORTED_ADMIN_VERSIONS.include?(api_version) raise Errors::UnsupportedVersionError, @@ -90,6 +96,8 @@ def setup( @response_as_struct = response_as_struct @rest_disabled = rest_disabled @expiring_offline_access_tokens = T.must(expiring_offline_access_tokens) + @open_timeout = open_timeout + @read_timeout = read_timeout @log_level = if valid_log_level?(log_level) log_level.to_sym else @@ -159,6 +167,10 @@ def private? sig { returns(T::Boolean) } attr_reader :expiring_offline_access_tokens + # Seconds to wait for a connection to open, and for each read, on API requests. nil keeps Net::HTTP's default. + sig { returns(T.nilable(T.any(Integer, Float))) } + attr_reader :open_timeout, :read_timeout + sig { returns(T::Boolean) } def embedded? @is_embedded diff --git a/test/clients/http_client_test.rb b/test/clients/http_client_test.rb index 3f2282d2b..b70d9c85d 100644 --- a/test/clients/http_client_test.rb +++ b/test/clients/http_client_test.rb @@ -310,6 +310,34 @@ def test_response_as_struct assert_equal("nested_value", response.body.key.nested_key) end + def test_request_applies_timeouts_from_context + modify_context(open_timeout: 5, read_timeout: 10) + apply_simple_http_stub + + Net::HTTP.any_instance.expects(:open_timeout=).with(5) + Net::HTTP.any_instance.expects(:read_timeout=).with(10) + + verify_http_request + end + + def test_request_keeps_net_http_timeouts_when_context_has_none + apply_simple_http_stub + + Net::HTTP.any_instance.expects(:open_timeout=).never + Net::HTTP.any_instance.expects(:read_timeout=).never + + verify_http_request + end + + def test_request_does_not_retry_a_timeout + stub_request(@request.http_method, "https://#{@shop}#{@base_path}/#{@request.path}") + .with(query: @request.query) + .to_raise(Net::ReadTimeout).then + .to_return(body: @success_body.to_json, headers: @response_headers) + + assert_raises(Net::ReadTimeout) { @client.request(@request) } + end + private def simple_http_test(http_method) diff --git a/test/context_test.rb b/test/context_test.rb index 569916b41..0a1994445 100644 --- a/test/context_test.rb +++ b/test/context_test.rb @@ -224,6 +224,32 @@ def test_expiring_offline_access_tokens_can_be_configured assert(ShopifyAPI::Context.expiring_offline_access_tokens) end + def test_timeouts_default_to_nil + ShopifyAPI::Context.setup( + api_key: "test-key", + api_secret_key: "test-secret-key", + api_version: "2023-01", + is_private: true, + is_embedded: false, + ) + assert_nil(ShopifyAPI::Context.open_timeout) + assert_nil(ShopifyAPI::Context.read_timeout) + end + + def test_timeouts_can_be_configured + ShopifyAPI::Context.setup( + api_key: "test-key", + api_secret_key: "test-secret-key", + api_version: "2023-01", + is_private: true, + is_embedded: false, + open_timeout: 5, + read_timeout: 10.5, + ) + assert_equal(5, ShopifyAPI::Context.open_timeout) + assert_equal(10.5, ShopifyAPI::Context.read_timeout) + end + def teardown ShopifyAPI::Context.deactivate_session end diff --git a/test/test_helper.rb b/test/test_helper.rb index b5a856f0a..09e039e1d 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -54,6 +54,8 @@ def setup response_as_struct: T.nilable(T::Boolean), api_host: T.nilable(String), expiring_offline_access_tokens: T.nilable(T::Boolean), + open_timeout: T.nilable(T.any(Integer, Float)), + read_timeout: T.nilable(T.any(Integer, Float)), ).void end def modify_context( @@ -70,7 +72,9 @@ def modify_context( old_api_secret_key: nil, response_as_struct: nil, api_host: nil, - expiring_offline_access_tokens: nil + expiring_offline_access_tokens: nil, + open_timeout: nil, + read_timeout: nil ) ShopifyAPI::Context.setup( api_key: api_key ? api_key : ShopifyAPI::Context.api_key, @@ -93,6 +97,8 @@ def modify_context( else ShopifyAPI::Context.expiring_offline_access_tokens end, + open_timeout: open_timeout || ShopifyAPI::Context.open_timeout, + read_timeout: read_timeout || ShopifyAPI::Context.read_timeout, ) end end