Compare commits

...

9 Commits

Author SHA1 Message Date
HoneyryderChuck
daf9fcee1d Merge branch 'issue-151' into 'master'
New http timeouts

Closes #151

See merge request honeyryderchuck/httpx!212
2022-08-07 14:03:39 +00:00
HoneyryderChuck
71cb66e287 added missing options sig 2022-08-07 14:43:29 +01:00
HoneyryderChuck
3f9c165d51 added request_timeout 2022-08-06 23:20:21 +01:00
HoneyryderChuck
c907942c9c unique domains for altsvc cache tests 2022-08-06 22:30:46 +01:00
HoneyryderChuck
d92de449ef backporting infinite method 2022-08-06 22:30:46 +01:00
HoneyryderChuck
7ee4c5f6d3 teaching errors how to dump 2022-08-06 22:30:46 +01:00
HoneyryderChuck
452657c805 Added the read_timeout and write_timeout timeouts
These are deadline oriented for the request and response, i.e. a write
timeout tracks the full time it takes to write the request, whereas the
read timeout does the same for receiving the response.

For back-compat, they're infinite by default. v1 may change that, and
will have to provide a safe fallback for endless "stream" requests and
responses.
2022-08-06 22:30:46 +01:00
HoneyryderChuck
f4e393af40 Response#finished? and Response.finish!
these new functions allow to mark an incomplete response as closed, such
as when a timeout happens. finishing also freezes the response headers.
2022-08-06 22:30:46 +01:00
HoneyryderChuck
ee49d7452c added tests for read and write timeout 2022-08-06 22:30:46 +01:00
20 changed files with 252 additions and 27 deletions

View File

@ -34,6 +34,7 @@ module HTTPX
include Callbacks
using URIExtensions
using NumericExtensions
require "httpx/connection/http2"
require "httpx/connection/http1"
@ -233,6 +234,7 @@ module HTTPX
# when pushing a request into an existing connection, we have to check whether there
# is the possibility that the connection might have extended the keep alive timeout.
# for such cases, we want to ping for availability before deciding to shovel requests.
log(level: 3) { "keep alive timeout expired, pinging connection..." }
@pending << request
parser.ping
transition(:active) if @state == :inactive
@ -430,6 +432,8 @@ module HTTPX
@inflight += 1
parser.send(request)
set_request_timeouts(request)
return unless @state == :inactive
transition(:active)
@ -591,10 +595,45 @@ module HTTPX
def handle_error(error)
parser.handle_error(error) if @parser && parser.respond_to?(:handle_error)
while (request = @pending.shift)
response = ErrorResponse.new(request, error, @options)
response = ErrorResponse.new(request, error, request.options)
request.response = response
request.emit(:response, response)
end
end
def set_request_timeouts(request)
write_timeout = request.write_timeout
request.once(:headers) do
@timers.after(write_timeout) { write_timeout_callback(request, write_timeout) }
end unless write_timeout.nil? || write_timeout.infinite?
read_timeout = request.read_timeout
request.once(:done) do
@timers.after(read_timeout) { read_timeout_callback(request, read_timeout) }
end unless read_timeout.nil? || read_timeout.infinite?
request_timeout = request.request_timeout
request.once(:headers) do
@timers.after(request_timeout) { read_timeout_callback(request, request_timeout, RequestTimeoutError) }
end unless request_timeout.nil? || request_timeout.infinite?
end
def write_timeout_callback(request, write_timeout)
return if request.state == :done
@write_buffer.clear
error = WriteTimeoutError.new(request, nil, write_timeout)
on_error(error)
end
def read_timeout_callback(request, read_timeout, error_type = ReadTimeoutError)
response = request.response
return if response && response.finished?
@write_buffer.clear
error = error_type.new(request, request.response, read_timeout)
on_error(error)
end
end
end

View File

@ -118,7 +118,7 @@ module HTTPX
log(color: :yellow) { response.headers.each.map { |f, v| "-> HEADER: #{f}: #{v}" }.join("\n") }
@request.response = response
on_complete if response.complete?
on_complete if response.finished?
end
def on_trailers(h)
@ -158,6 +158,7 @@ module HTTPX
@request = nil
@requests.shift
response = request.response
response.finish!
emit(:response, request, response)
if @parser.upgrade?

View File

@ -24,6 +24,24 @@ module HTTPX
class ConnectTimeoutError < TimeoutError; end
class RequestTimeoutError < TimeoutError
attr_reader :request
def initialize(request, response, timeout)
@request = request
@response = response
super(timeout, "Timed out after #{timeout} seconds")
end
def marshal_dump
[message]
end
end
class ReadTimeoutError < RequestTimeoutError; end
class WriteTimeoutError < RequestTimeoutError; end
class SettingsTimeoutError < TimeoutError; end
class ResolveTimeoutError < TimeoutError; end

View File

@ -54,6 +54,14 @@ module HTTPX
Numeric.__send__(:include, NegMethods)
end
module NumericExtensions
refine Numeric do
def infinite?
self == Float::INFINITY
end unless Numeric.method_defined?(:infinite?)
end
end
module StringExtensions
refine String do
def delete_suffix!(suffix)

View File

@ -10,6 +10,7 @@ module HTTPX
OPERATION_TIMEOUT = 60
KEEP_ALIVE_TIMEOUT = 20
SETTINGS_TIMEOUT = 10
READ_TIMEOUT = WRITE_TIMEOUT = REQUEST_TIMEOUT = Float::INFINITY
# https://github.com/ruby/resolv/blob/095f1c003f6073730500f02acbdbc55f83d70987/lib/resolv.rb#L408
ip_address_families = begin
@ -34,6 +35,9 @@ module HTTPX
settings_timeout: SETTINGS_TIMEOUT,
operation_timeout: OPERATION_TIMEOUT,
keep_alive_timeout: KEEP_ALIVE_TIMEOUT,
read_timeout: READ_TIMEOUT,
write_timeout: WRITE_TIMEOUT,
request_timeout: REQUEST_TIMEOUT,
},
:headers => {},
:window_size => WINDOW_SIZE,

View File

@ -64,6 +64,18 @@ module HTTPX
@state = :idle
end
def read_timeout
@options.timeout[:read_timeout]
end
def write_timeout
@options.timeout[:write_timeout]
end
def request_timeout
@options.timeout[:request_timeout]
end
def trailers?
defined?(@trailers)
end
@ -108,6 +120,7 @@ module HTTPX
def path
path = uri.path.dup
path = +"" if path.nil?
path << "/" if path.empty?
path << "?#{query}" unless query.empty?
path

View File

@ -77,7 +77,8 @@ module HTTPX
nil
rescue Errno::EHOSTUNREACH => e
@ns_index += 1
if @ns_index < @nameserver.size
nameserver = @nameserver
if nameserver && @ns_index < nameserver.size
log { "resolver: failed resolving on nameserver #{@nameserver[@ns_index - 1]} (#{e.message})" }
transition(:idle)
else

View File

@ -31,6 +31,7 @@ module HTTPX
@status = Integer(status)
@headers = @options.headers_class.new(headers)
@body = @options.response_body_class.new(self, @options)
@finished = complete?
end
def merge_headers(h)
@ -41,15 +42,24 @@ module HTTPX
@body.write(data)
end
def content_type
@content_type ||= ContentType.new(@headers["content-type"])
end
def finished?
@finished
end
def finish!
@finished = true
@headers.freeze
end
def bodyless?
@request.verb == :head ||
no_data?
end
def content_type
@content_type ||= ContentType.new(@headers["content-type"])
end
def complete?
bodyless? || (@request.verb == :connect && @status == 200)
end
@ -102,7 +112,7 @@ module HTTPX
end
def no_data?
@status < 200 ||
@status < 200 || # informational response
@status == 204 ||
@status == 205 ||
@status == 304 || begin
@ -339,6 +349,10 @@ module HTTPX
end
end
def finished?
true
end
def raise_for_status
raise @error
end

View File

@ -37,9 +37,12 @@ module HTTPX
elapsed_time = Utils.elapsed_time(@next_interval_at)
@intervals.delete_if { |interval| interval.elapse(elapsed_time) <= 0 }
@next_interval_at = nil if @intervals.empty?
end
def cancel
@next_interval_at = nil
@intervals.clear
end

View File

@ -28,6 +28,7 @@ module HTTPX
attr_reader options: Options
attr_writer timers: Timers
@type: io_type
@origins: Array[URI::Generic]
@window_size: Integer
@read_buffer: Buffer
@ -40,7 +41,7 @@ module HTTPX
def addresses=: (Array[ipaddr]) -> void
def match?: (URI::Generic, options) -> bool
def match?: (URI::Generic uri, Options options) -> bool
def mergeable?: (Connection) -> bool
@ -54,13 +55,15 @@ module HTTPX
def match_altsvcs?: (URI::Generic uri) -> bool
def match_altsvc_options?: (URI::Generic uri, Options options) -> bool
def connecting?: () -> bool
def inflight?: () -> boolish
def interests: () -> io_interests?
def to_io: () -> IO
def to_io: () -> ::IO
def call: () -> void
@ -77,7 +80,7 @@ module HTTPX
private
def initialize: (String, URI::Generic, options) -> untyped
def initialize: (io_type, URI::Generic, options) -> untyped
def connect: () -> void
@ -103,5 +106,11 @@ module HTTPX
def handle_error: (StandardError) -> void
def purge_after_closed: () -> void
def set_request_timeouts: (Request request) -> void
def write_timeout_callback: (Request request, Numeric write_timeout) -> void
def read_timeout_callback: (Request request, Numeric read_timeout, ?singleton(RequestTimeoutError) error_type) -> void
end
end

View File

@ -23,6 +23,19 @@ module HTTPX
class ResolveTimeoutError < TimeoutError
end
class RequestTimeoutError < TimeoutError
attr_reader request: Request
attr_reader response: response?
def initialize: (Request request, response? response, Numeric timeout) -> void
end
class ReadTimeoutError < RequestTimeoutError
end
class WriteTimeoutError < RequestTimeoutError
end
class ResolveError < Error
end

6
sig/io.rbs Normal file
View File

@ -0,0 +1,6 @@
module HTTPX
type io_type = "udp" | "tcp" | "ssl" | "unix"
module IO
end
end

View File

@ -10,7 +10,7 @@ module HTTPX
SETTINGS_TIMEOUT: Integer
DEFAULT_OPTIONS: Hash[Symbol, untyped]
type timeout_type = :connect_timeout | :settings_timeout | :operation_timeout | :keep_alive_timeout | :total_timeout
type timeout_type = :connect_timeout | :settings_timeout | :operation_timeout | :keep_alive_timeout | :total_timeout | :read_timeout | :write_timeout | :request_timeout
type timeout = Hash[timeout_type, Numeric]
def self.new: (?options) -> instance
@ -65,6 +65,9 @@ module HTTPX
# body
attr_reader origin: URI::Generic?
# base_path
attr_reader base_path: String?
# ssl
# http2_settings

View File

@ -50,6 +50,12 @@ module HTTPX
def trailers?: () -> boolish
def read_timeout: () -> Numeric
def write_timeout: () -> Numeric
def request_timeout: () -> Numeric
class Body
@headers: Headers
@body: body_encoder?

View File

@ -10,7 +10,10 @@ module HTTPX
@family: ip_family
@options: Options
@ns_index: Integer
@nameserver: String
@nameserver: Array[String]?
@ndots: Integer
@start_timeout: Numeric?
@search: Array[String]
@_timeouts: Array[Numeric]
@timeouts: Hash[String, Array[Numeric]]
@connections: Array[Connection]
@ -37,7 +40,7 @@ module HTTPX
def consume: () -> void
def do_retry: (?Numeric loop_time) -> void
def do_retry: (?Numeric? loop_time) -> void
def dread: (Integer) -> void
| () -> void

View File

@ -1,5 +1,7 @@
module HTTPX
interface _Response
def finished?: () -> bool
def raise_for_status: () -> self
def error: () -> StandardError?

View File

@ -6,14 +6,14 @@ class AltSvcTest < Minitest::Test
include HTTPX
def test_altsvc_cache
assert AltSvc.cached_altsvc("http://www.example.com").empty?
AltSvc.cached_altsvc_set("http://www.example.com", { "origin" => "http://alt.example.com", "ma" => 2 })
entries = AltSvc.cached_altsvc("http://www.example.com")
assert AltSvc.cached_altsvc("http://www.example-altsvc-cache.com").empty?
AltSvc.cached_altsvc_set("http://www.example-altsvc-cache.com", { "origin" => "http://alt.example-altsvc-cache.com", "ma" => 2 })
entries = AltSvc.cached_altsvc("http://www.example-altsvc-cache.com")
assert !entries.empty?
entry = entries.first
assert entry["origin"] == "http://alt.example.com"
assert entry["origin"] == "http://alt.example-altsvc-cache.com"
sleep 3
assert AltSvc.cached_altsvc("http://www.example.com").empty?
assert AltSvc.cached_altsvc("http://www.example-altsvc-cache.com").empty?
end
def test_altsvc_parse_svc
@ -52,16 +52,16 @@ class AltSvcTest < Minitest::Test
end
def test_altsvc_clear_cache
AltSvc.cached_altsvc_set("http://www.example.com", { "origin" => "http://alt.example.com", "ma" => 2 })
entries = AltSvc.cached_altsvc("http://www.example.com")
AltSvc.cached_altsvc_set("http://www.example-clear-cache.com", { "origin" => "http://alt.example-clear-cache.com", "ma" => 2 })
entries = AltSvc.cached_altsvc("http://www.example-clear-cache.com")
assert !entries.empty?
req = Request.new(:get, "http://www.example.com/")
req = Request.new(:get, "http://www.example-clear-cache.com/")
res = Response.new(req, 200, "2.0", { "alt-svc" => "clear" })
AltSvc.emit(req, res)
entries = AltSvc.cached_altsvc("http://www.example.com")
entries = AltSvc.cached_altsvc("http://www.example-clear-cache.com")
assert entries.empty?
end
end

View File

@ -107,6 +107,9 @@ class OptionsTest < Minitest::Test
settings_timeout: 10,
operation_timeout: 60,
keep_alive_timeout: 20,
read_timeout: Float::INFINITY,
write_timeout: Float::INFINITY,
request_timeout: Float::INFINITY,
},
:ssl => { :foo => "bar" },
:http2_settings => { :settings_enable_push => 0 },

View File

@ -3,19 +3,18 @@
require_relative "test_helper"
class SessionTest < Minitest::Test
include HTTPX
include HTTPHelpers
def test_session_block
yielded = nil
Session.new do |cli|
HTTPX::Session.new do |cli|
yielded = cli
end
assert yielded.is_a?(Session), "session should have been yielded"
assert yielded.is_a?(HTTPX::Session), "session should have been yielded"
end
def test_session_plugin
klient_class = Class.new(Session)
klient_class = Class.new(HTTPX::Session)
klient_class.plugin(TestPlugin)
session = klient_class.new
assert session.respond_to?(:foo), "instance methods weren't added"
@ -69,6 +68,40 @@ class SessionTest < Minitest::Test
end
end
def test_session_timeouts_read_timeout
uri = build_uri("/drip?numbytes=10&duration=4&delay=2&code=200")
session = HTTPX.with_timeout(read_timeout: 3, operation_timeout: 10)
response = session.get(uri)
verify_error_response(response, HTTPX::ReadTimeoutError)
uri = build_uri("/drip?numbytes=10&duration=1&delay=0&code=200")
response1 = session.get(uri)
verify_status(response1, 200)
end
def test_session_timeouts_write_timeout
start_test_servlet(SlowReader) do |server|
uri = URI("#{server.origin}/")
session = HTTPX.with(timeout: { write_timeout: 4, operation_timeout: 10 })
response = session.post(uri, body: StringIO.new("a" * 65_536 * 3 * 5))
verify_error_response(response, HTTPX::WriteTimeoutError)
response1 = session.post(uri, body: StringIO.new("a" * 65_536 * 2 * 5))
verify_status(response1, 200)
end
end
def test_session_timeouts_request_timeout
uri = build_uri("/drip?numbytes=10&duration=4&delay=2&code=200")
session = HTTPX.with_timeout(request_timeout: 3, operation_timeout: 10)
response = session.get(uri)
verify_error_response(response, HTTPX::RequestTimeoutError)
uri = build_uri("/drip?numbytes=10&duration=1&delay=0&code=200")
response1 = session.get(uri)
verify_status(response1, 200)
end
# def test_http_timeouts_operation_timeout
# uri = build_uri("/delay/2")
# session = HTTPX.with_timeout(operation_timeout: 1)

View File

@ -0,0 +1,46 @@
# frozen_string_literal: true
require "socket"
class SlowReader
def initialize
@server = TCPServer.new("127.0.0.1", 0)
@server.setsockopt(Socket::SOL_SOCKET, Socket::SO_RCVBUF, 5)
@can_log = ENV.key?("HTTPX_DEBUG")
end
def origin
_, sock, ip, _ = @server.addr
"http://#{ip}:#{sock}"
end
def start
loop do
sock = @server.accept
begin
sock.setsockopt(Socket::SOL_SOCKET, Socket::SO_RCVBUF, 5)
request = +""
5.times do
request << sock.readpartial(2048)
warn "buffered request: #{request.size} (closed? #{sock.closed?})" if @can_log
sleep(1)
end
sock.setsockopt(Socket::SOL_SOCKET, Socket::SO_RCVBUF, 65_535)
request << sock.readpartial(2048)
# warn "request: #{request.size}" if @can_log
response = "HTTP/1.1 200\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
sock.puts(response)
rescue IOError => e
warn e.message
ensure
sock.close
end
end
rescue IOError
end
def shutdown
@server.close
end
end