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: 1 addition & 1 deletion lib/openai/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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]
#
Expand Down
14 changes: 7 additions & 7 deletions lib/openai/internal/transport/base_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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]
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -385,7 +385,7 @@ def initialize(
#
# @option request [Integer] :max_retries
#
# @option request [Float] :timeout
# @option request [Float, nil] :timeout
#
# @param redirect_count [Integer]
#
Expand All @@ -397,7 +397,7 @@ def initialize(
# @return [Array(Integer, Net::HTTPResponse, Enumerable<String>)]
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
Expand Down Expand Up @@ -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
Expand Down
26 changes: 19 additions & 7 deletions lib/openai/internal/transport/pooled_net_requester.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -94,22 +95,31 @@ 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
self.class.connect(cert_store: @cert_store, url: url)
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
Expand All @@ -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<String>)]
def execute(request)
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion lib/openai/request_options.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
84 changes: 84 additions & 0 deletions test/openai/client_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
30 changes: 30 additions & 0 deletions test/openai/internal/transport/pooled_net_requester_test.rb
Original file line number Diff line number Diff line change
@@ -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