diff --git a/lib/openai/client.rb b/lib/openai/client.rb index 930fa565..77b8286e 100644 --- a/lib/openai/client.rb +++ b/lib/openai/client.rb @@ -223,7 +223,7 @@ class Client < OpenAI::Internal::Transport::BaseClient # # @param max_retries [Integer] Max number of retries to attempt after a failed retryable request. # - # @param timeout [Float] + # @param timeout [Float, nil] Pass `nil` to disable request timeouts. # # @param initial_retry_delay [Float] # diff --git a/lib/openai/internal/transport/base_client.rb b/lib/openai/internal/transport/base_client.rb index 22db48eb..8bd57804 100644 --- a/lib/openai/internal/transport/base_client.rb +++ b/lib/openai/internal/transport/base_client.rb @@ -168,7 +168,7 @@ def reap_connection!(status, stream:) # @return [URI::Generic] attr_reader :base_url - # @return [Float] + # @return [Float, nil] attr_reader :timeout # @return [Integer] @@ -193,7 +193,7 @@ def reap_connection!(status, stream:) # @api private # # @param base_url [String] - # @param timeout [Float] + # @param timeout [Float, nil] # @param max_retries [Integer] # @param initial_retry_delay [Float] # @param max_retry_delay [Float] @@ -310,8 +310,8 @@ def initialize( headers["x-stainless-retry-count"] = "0" end - timeout = opts.fetch(:timeout, @timeout).to_f.clamp(0..) - unless headers.key?("x-stainless-timeout") || timeout.zero? + timeout = opts.fetch(:timeout, @timeout)&.to_f&.clamp(0..) + unless headers.key?("x-stainless-timeout") || timeout.nil? || timeout.zero? headers["x-stainless-timeout"] = timeout.to_s end @@ -385,7 +385,7 @@ def initialize( # # @option request [Integer] :max_retries # - # @option request [Float] :timeout + # @option request [Float, nil] :timeout # # @param redirect_count [Integer] # @@ -397,7 +397,7 @@ def initialize( # @return [Array(Integer, Net::HTTPResponse, Enumerable)] def send_request(request, redirect_count:, retry_count:, send_retry_header:) url, headers, max_retries, timeout = request.fetch_values(:url, :headers, :max_retries, :timeout) - input = {**request.except(:timeout), deadline: OpenAI::Internal::Util.monotonic_secs + timeout} + input = {**request.except(:timeout), deadline: timeout.nil? ? nil : OpenAI::Internal::Util.monotonic_secs + timeout} if send_retry_header headers["x-stainless-retry-count"] = retry_count.to_s @@ -590,7 +590,7 @@ def inspect headers: T::Hash[String, String], body: T.anything, max_retries: Integer, - timeout: Float + timeout: T.nilable(Float) } end end diff --git a/lib/openai/internal/transport/pooled_net_requester.rb b/lib/openai/internal/transport/pooled_net_requester.rb index 4d010da2..d0534940 100644 --- a/lib/openai/internal/transport/pooled_net_requester.rb +++ b/lib/openai/internal/transport/pooled_net_requester.rb @@ -12,6 +12,7 @@ class PooledNetRequester KEEP_ALIVE_TIMEOUT = 30 DEFAULT_MAX_CONNECTIONS = [Etc.nprocessors, 99].max + UNBOUNDED_POOL_WAIT_TIMEOUT = 60 class << self # @api private @@ -42,9 +43,9 @@ def connect(cert_store:, url:) # @api private # # @param conn [Net::HTTP] - # @param deadline [Float] + # @param deadline [Float, nil] def calibrate_socket_timeout(conn, deadline) - timeout = deadline - OpenAI::Internal::Util.monotonic_secs + timeout = deadline&.-(OpenAI::Internal::Util.monotonic_secs) conn.open_timeout = conn.read_timeout = conn.write_timeout = conn.continue_timeout = timeout end @@ -94,14 +95,13 @@ def build_request(request, &blk) # @api private # # @param url [URI::Generic] - # @param deadline [Float] + # @param deadline [Float, nil] # @param blk [Proc] # # @raise [Timeout::Error] # @yieldparam [Net::HTTP] private def with_pool(url, deadline:, &blk) origin = OpenAI::Internal::Util.uri_origin(url) - timeout = deadline - OpenAI::Internal::Util.monotonic_secs pool = @mutex.synchronize do @pools[origin] ||= ConnectionPool.new(size: @size) do @@ -109,7 +109,17 @@ def build_request(request, &blk) end end - pool.with(timeout: timeout, &blk) + if deadline.nil? + loop do + # `connection_pool` cannot disable checkout timeouts, so retry a long + # wait interval indefinitely when the request timeout is disabled. + return pool.with(timeout: self.class::UNBOUNDED_POOL_WAIT_TIMEOUT, &blk) + rescue ConnectionPool::TimeoutError + end + else + timeout = deadline - OpenAI::Internal::Util.monotonic_secs + pool.with(timeout: timeout, &blk) + end end # @api private @@ -124,7 +134,7 @@ def build_request(request, &blk) # # @option request [Object] :body # - # @option request [Float] :deadline + # @option request [Float, nil] :deadline # # @return [Array(Integer, Net::HTTPResponse, Enumerable)] def execute(request) @@ -202,7 +212,9 @@ def initialize(size: self.class::DEFAULT_MAX_CONNECTIONS) end define_sorbet_constant!(:Request) do - T.type_alias { {method: Symbol, url: URI::Generic, headers: T::Hash[String, String], body: T.anything, deadline: Float} } + T.type_alias do + {method: Symbol, url: URI::Generic, headers: T::Hash[String, String], body: T.anything, deadline: T.nilable(Float)} + end end end end diff --git a/lib/openai/request_options.rb b/lib/openai/request_options.rb index ed62d70f..5ca48b82 100644 --- a/lib/openai/request_options.rb +++ b/lib/openai/request_options.rb @@ -60,7 +60,7 @@ def self.validate!(opts) optional :max_retries, Integer # @!attribute timeout - # Request timeout in seconds. + # Request timeout in seconds. Pass `nil` to disable the timeout. # # @return [Float, nil] optional :timeout, Float diff --git a/test/openai/client_test.rb b/test/openai/client_test.rb index e243c432..f99f164a 100644 --- a/test/openai/client_test.rb +++ b/test/openai/client_test.rb @@ -366,6 +366,50 @@ def test_overwrite_retry_count_header assert_requested(:any, /./, headers: {"x-stainless-retry-count" => "42"}, times: 3) end + def test_request_timeout_nil_omits_timeout_header_and_deadline + response_class = + Struct.new(:headers) do + def each_header = headers.each + end + + requester = + Class.new do + attr_reader :request + + define_method(:initialize) do |response_class| + @response_class = response_class + end + + def execute(request) + @request = request + [200, @response_class.new({}), [].each] + end + end.new(response_class) + + openai = + OpenAI::Client.new( + base_url: "http://localhost", + api_key: "My API Key", + admin_api_key: "My Admin API Key", + timeout: 30 + ) + openai.instance_variable_set(:@requester, requester) + + request = + openai.send( + :build_request, + {method: :post, path: "/chat/completions", body: {}, headers: nil, query: nil}, + {timeout: nil} + ) + + assert_nil(request[:timeout]) + refute_includes(request[:headers].keys.map(&:downcase), "x-stainless-timeout") + + openai.send(:send_request, request, redirect_count: 0, retry_count: 0, send_retry_header: true) + + assert_nil(requester.request[:deadline]) + end + def test_client_redirect_307 stub_request(:post, "http://localhost/chat/completions").to_return_json( status: 307, @@ -523,4 +567,44 @@ def test_default_headers headers.fetch_values(*expected).each { refute_empty(_1) } end end + + def test_client_timeout_nil_disables_timeout_header + stub_request(:post, "http://localhost/chat/completions").to_return_json(status: 200, body: {}) + + openai = + OpenAI::Client.new( + base_url: "http://localhost", + api_key: "My API Key", + admin_api_key: "My Admin API Key", + timeout: nil + ) + + openai.chat.completions.create(messages: [{content: "string", role: :developer}], model: :"gpt-5.4") + + assert_requested(:post, "http://localhost/chat/completions") do |req| + refute_includes(req.headers.keys.map(&:downcase), "x-stainless-timeout") + end + end + + def test_request_timeout_nil_overrides_client_default_timeout_header + stub_request(:post, "http://localhost/chat/completions").to_return_json(status: 200, body: {}) + + openai = + OpenAI::Client.new( + base_url: "http://localhost", + api_key: "My API Key", + admin_api_key: "My Admin API Key", + timeout: 30 + ) + + openai.chat.completions.create( + messages: [{content: "string", role: :developer}], + model: :"gpt-5.4", + request_options: {timeout: nil} + ) + + assert_requested(:post, "http://localhost/chat/completions") do |req| + refute_includes(req.headers.keys.map(&:downcase), "x-stainless-timeout") + end + end end diff --git a/test/openai/internal/transport/pooled_net_requester_test.rb b/test/openai/internal/transport/pooled_net_requester_test.rb new file mode 100644 index 00000000..38fda158 --- /dev/null +++ b/test/openai/internal/transport/pooled_net_requester_test.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +class OpenAI::Test::PooledNetRequesterTest < Minitest::Test + def test_with_pool_waits_indefinitely_when_deadline_is_nil + requester = OpenAI::Internal::Transport::PooledNetRequester.new(size: 1) + pool = ConnectionPool.new(size: 1, timeout: 0.05) { Object.new } + origin = OpenAI::Internal::Util.uri_origin(URI("http://localhost")) + requester.instance_variable_get(:@pools)[origin] = pool + + pool.checkout + + result = Queue.new + waiter = + Thread.new do + begin + requester.send(:with_pool, URI("http://localhost"), deadline: nil) { result << :acquired } + rescue StandardError => e + result << e + end + end + + sleep 0.15 + pool.checkin + + assert(waiter.join(1), "expected waiting thread to finish after the connection was returned") + assert_equal(:acquired, result.pop) + end +end