httpx 1.4.4 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/doc/release_notes/1_5_0.md +126 -0
- data/lib/httpx/adapters/datadog.rb +24 -3
- data/lib/httpx/adapters/webmock.rb +1 -0
- data/lib/httpx/buffer.rb +16 -5
- data/lib/httpx/connection/http1.rb +8 -9
- data/lib/httpx/connection/http2.rb +48 -24
- data/lib/httpx/connection.rb +36 -19
- data/lib/httpx/errors.rb +2 -11
- data/lib/httpx/headers.rb +24 -23
- data/lib/httpx/io/ssl.rb +2 -1
- data/lib/httpx/io/tcp.rb +9 -7
- data/lib/httpx/io/unix.rb +1 -1
- data/lib/httpx/loggable.rb +13 -1
- data/lib/httpx/options.rb +63 -48
- data/lib/httpx/parser/http1.rb +1 -1
- data/lib/httpx/plugins/aws_sigv4.rb +1 -0
- data/lib/httpx/plugins/callbacks.rb +19 -6
- data/lib/httpx/plugins/circuit_breaker.rb +4 -3
- data/lib/httpx/plugins/cookies/jar.rb +0 -2
- data/lib/httpx/plugins/cookies/set_cookie_parser.rb +7 -4
- data/lib/httpx/plugins/cookies.rb +4 -4
- data/lib/httpx/plugins/follow_redirects.rb +4 -2
- data/lib/httpx/plugins/grpc/call.rb +1 -1
- data/lib/httpx/plugins/h2c.rb +7 -1
- data/lib/httpx/plugins/persistent.rb +22 -1
- data/lib/httpx/plugins/proxy/http.rb +3 -1
- data/lib/httpx/plugins/query.rb +35 -0
- data/lib/httpx/plugins/response_cache/file_store.rb +115 -15
- data/lib/httpx/plugins/response_cache/store.rb +7 -67
- data/lib/httpx/plugins/response_cache.rb +179 -29
- data/lib/httpx/plugins/retries.rb +26 -14
- data/lib/httpx/plugins/stream.rb +4 -2
- data/lib/httpx/plugins/stream_bidi.rb +315 -0
- data/lib/httpx/pool.rb +58 -5
- data/lib/httpx/request/body.rb +1 -1
- data/lib/httpx/request.rb +6 -2
- data/lib/httpx/resolver/https.rb +10 -4
- data/lib/httpx/resolver/native.rb +13 -13
- data/lib/httpx/resolver/resolver.rb +4 -0
- data/lib/httpx/resolver/system.rb +37 -14
- data/lib/httpx/resolver.rb +2 -2
- data/lib/httpx/response/body.rb +10 -21
- data/lib/httpx/response/buffer.rb +36 -12
- data/lib/httpx/response.rb +11 -1
- data/lib/httpx/selector.rb +16 -12
- data/lib/httpx/session.rb +79 -19
- data/lib/httpx/timers.rb +24 -16
- data/lib/httpx/transcoder/multipart/decoder.rb +4 -2
- data/lib/httpx/transcoder/multipart/encoder.rb +2 -1
- data/lib/httpx/version.rb +1 -1
- data/sig/buffer.rbs +1 -1
- data/sig/chainable.rbs +5 -2
- data/sig/connection/http2.rbs +11 -2
- data/sig/connection.rbs +4 -4
- data/sig/errors.rbs +0 -3
- data/sig/headers.rbs +15 -10
- data/sig/httpx.rbs +5 -1
- data/sig/io/tcp.rbs +6 -0
- data/sig/loggable.rbs +2 -0
- data/sig/options.rbs +7 -1
- data/sig/plugins/cookies/cookie.rbs +1 -3
- data/sig/plugins/cookies/jar.rbs +4 -4
- data/sig/plugins/cookies/set_cookie_parser.rbs +22 -0
- data/sig/plugins/cookies.rbs +2 -0
- data/sig/plugins/h2c.rbs +4 -0
- data/sig/plugins/proxy/http.rbs +3 -0
- data/sig/plugins/proxy.rbs +4 -0
- data/sig/plugins/query.rbs +18 -0
- data/sig/plugins/response_cache/file_store.rbs +19 -0
- data/sig/plugins/response_cache/store.rbs +13 -0
- data/sig/plugins/response_cache.rbs +41 -19
- data/sig/plugins/retries.rbs +4 -3
- data/sig/plugins/stream.rbs +5 -1
- data/sig/plugins/stream_bidi.rbs +68 -0
- data/sig/plugins/upgrade/h2.rbs +9 -0
- data/sig/plugins/upgrade.rbs +5 -0
- data/sig/pool.rbs +5 -0
- data/sig/punycode.rbs +5 -0
- data/sig/request.rbs +2 -0
- data/sig/resolver/https.rbs +3 -2
- data/sig/resolver/native.rbs +1 -2
- data/sig/resolver/resolver.rbs +11 -3
- data/sig/resolver/system.rbs +19 -2
- data/sig/resolver.rbs +11 -7
- data/sig/response/body.rbs +3 -4
- data/sig/response/buffer.rbs +2 -3
- data/sig/response.rbs +2 -2
- data/sig/selector.rbs +20 -10
- data/sig/session.rbs +14 -6
- data/sig/timers.rbs +5 -7
- data/sig/transcoder/multipart.rbs +4 -3
- metadata +13 -2
@@ -1,39 +1,139 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require "pathname"
|
4
|
-
require_relative "store"
|
5
4
|
|
6
5
|
module HTTPX::Plugins
|
7
6
|
module ResponseCache
|
8
|
-
|
7
|
+
# Implementation of a file system based cache store.
|
8
|
+
#
|
9
|
+
# It stores cached responses in a file under a directory pointed by the +dir+
|
10
|
+
# variable (defaults to the default temp directory from the OS), in a custom
|
11
|
+
# format (similar but different from HTTP/1.1 request/response framing).
|
12
|
+
class FileStore
|
13
|
+
CRLF = HTTPX::Connection::HTTP1::CRLF
|
14
|
+
|
15
|
+
attr_reader :dir
|
16
|
+
|
9
17
|
def initialize(dir = Dir.tmpdir)
|
10
|
-
@dir = Pathname.new(dir)
|
18
|
+
@dir = Pathname.new(dir).join("httpx-response-cache")
|
19
|
+
|
20
|
+
FileUtils.mkdir_p(@dir)
|
11
21
|
end
|
12
22
|
|
13
23
|
def clear
|
14
|
-
|
24
|
+
FileUtils.rm_rf(@dir)
|
15
25
|
end
|
16
26
|
|
17
|
-
def
|
18
|
-
|
27
|
+
def get(request)
|
28
|
+
path = file_path(request)
|
29
|
+
|
30
|
+
return unless File.exist?(path)
|
31
|
+
|
32
|
+
File.open(path, mode: File::RDONLY | File::BINARY) do |f|
|
33
|
+
f.flock(File::Constants::LOCK_SH)
|
19
34
|
|
20
|
-
|
35
|
+
read_from_file(request, f)
|
36
|
+
end
|
21
37
|
end
|
22
38
|
|
23
|
-
|
39
|
+
def set(request, response)
|
40
|
+
path = file_path(request)
|
41
|
+
|
42
|
+
file_exists = File.exist?(path)
|
43
|
+
|
44
|
+
mode = file_exists ? File::RDWR : File::CREAT | File::Constants::WRONLY
|
45
|
+
|
46
|
+
File.open(path, mode: mode | File::BINARY) do |f|
|
47
|
+
f.flock(File::Constants::LOCK_EX)
|
48
|
+
|
49
|
+
if file_exists
|
50
|
+
cached_response = read_from_file(request, f)
|
51
|
+
|
52
|
+
if cached_response
|
53
|
+
next if cached_response == request.cached_response
|
54
|
+
|
55
|
+
cached_response.close
|
56
|
+
|
57
|
+
f.truncate(0)
|
58
|
+
|
59
|
+
f.rewind
|
60
|
+
end
|
61
|
+
end
|
62
|
+
# cache the request headers
|
63
|
+
f << request.verb << CRLF
|
64
|
+
f << request.uri << CRLF
|
65
|
+
|
66
|
+
request.headers.each do |field, value|
|
67
|
+
f << field << ":" << value << CRLF
|
68
|
+
end
|
69
|
+
|
70
|
+
f << CRLF
|
71
|
+
|
72
|
+
# cache the response
|
73
|
+
f << response.status << CRLF
|
74
|
+
f << response.version << CRLF
|
24
75
|
|
25
|
-
|
26
|
-
|
76
|
+
response.headers.each do |field, value|
|
77
|
+
f << field << ":" << value << CRLF
|
78
|
+
end
|
27
79
|
|
28
|
-
|
80
|
+
f << CRLF
|
81
|
+
|
82
|
+
response.body.rewind
|
83
|
+
|
84
|
+
::IO.copy_stream(response.body, f)
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
private
|
89
|
+
|
90
|
+
def file_path(request)
|
91
|
+
@dir.join(request.response_cache_key)
|
29
92
|
end
|
30
93
|
|
31
|
-
def
|
32
|
-
|
94
|
+
def read_from_file(request, f)
|
95
|
+
# if it's an empty file
|
96
|
+
return if f.eof?
|
97
|
+
|
98
|
+
# read request data
|
99
|
+
verb = f.readline.delete_suffix!(CRLF)
|
100
|
+
uri = f.readline.delete_suffix!(CRLF)
|
101
|
+
|
102
|
+
request_headers = {}
|
103
|
+
while (line = f.readline) != CRLF
|
104
|
+
line.delete_suffix!(CRLF)
|
105
|
+
sep_index = line.index(":")
|
106
|
+
|
107
|
+
field = line.byteslice(0..(sep_index - 1))
|
108
|
+
value = line.byteslice((sep_index + 1)..-1)
|
109
|
+
|
110
|
+
request_headers[field] = value
|
111
|
+
end
|
112
|
+
|
113
|
+
status = f.readline.delete_suffix!(CRLF)
|
114
|
+
version = f.readline.delete_suffix!(CRLF)
|
115
|
+
|
116
|
+
response_headers = {}
|
117
|
+
while (line = f.readline) != CRLF
|
118
|
+
line.delete_suffix!(CRLF)
|
119
|
+
sep_index = line.index(":")
|
120
|
+
|
121
|
+
field = line.byteslice(0..(sep_index - 1))
|
122
|
+
value = line.byteslice((sep_index + 1)..-1)
|
123
|
+
|
124
|
+
response_headers[field] = value
|
125
|
+
end
|
126
|
+
|
127
|
+
original_request = request.options.request_class.new(verb, uri, request.options)
|
128
|
+
original_request.merge_headers(request_headers)
|
129
|
+
|
130
|
+
response = request.options.response_class.new(request, status, version, response_headers)
|
131
|
+
response.original_request = original_request
|
132
|
+
response.finish!
|
33
133
|
|
34
|
-
response.
|
134
|
+
::IO.copy_stream(f, response.body)
|
35
135
|
|
36
|
-
response
|
136
|
+
response
|
37
137
|
end
|
38
138
|
end
|
39
139
|
end
|
@@ -2,6 +2,7 @@
|
|
2
2
|
|
3
3
|
module HTTPX::Plugins
|
4
4
|
module ResponseCache
|
5
|
+
# Implementation of a thread-safe in-memory cache store.
|
5
6
|
class Store
|
6
7
|
def initialize
|
7
8
|
@store = {}
|
@@ -12,80 +13,19 @@ module HTTPX::Plugins
|
|
12
13
|
@store_mutex.synchronize { @store.clear }
|
13
14
|
end
|
14
15
|
|
15
|
-
def
|
16
|
-
responses = _get(request)
|
17
|
-
|
18
|
-
return unless responses
|
19
|
-
|
20
|
-
responses.find(&method(:match_by_vary?).curry(2)[request])
|
21
|
-
end
|
22
|
-
|
23
|
-
def cached?(request)
|
24
|
-
lookup(request)
|
25
|
-
end
|
26
|
-
|
27
|
-
def cache(request, response)
|
28
|
-
return unless ResponseCache.cacheable_request?(request) && ResponseCache.cacheable_response?(response)
|
29
|
-
|
30
|
-
_set(request, response)
|
31
|
-
end
|
32
|
-
|
33
|
-
def prepare(request)
|
34
|
-
cached_response = lookup(request)
|
35
|
-
|
36
|
-
return unless cached_response
|
37
|
-
|
38
|
-
return unless match_by_vary?(request, cached_response)
|
39
|
-
|
40
|
-
if !request.headers.key?("if-modified-since") && (last_modified = cached_response.headers["last-modified"])
|
41
|
-
request.headers.add("if-modified-since", last_modified)
|
42
|
-
end
|
43
|
-
|
44
|
-
if !request.headers.key?("if-none-match") && (etag = cached_response.headers["etag"]) # rubocop:disable Style/GuardClause
|
45
|
-
request.headers.add("if-none-match", etag)
|
46
|
-
end
|
47
|
-
end
|
48
|
-
|
49
|
-
private
|
50
|
-
|
51
|
-
def match_by_vary?(request, response)
|
52
|
-
vary = response.vary
|
53
|
-
|
54
|
-
return true unless vary
|
55
|
-
|
56
|
-
original_request = response.instance_variable_get(:@request)
|
57
|
-
|
58
|
-
return request.headers.same_headers?(original_request.headers) if vary == %w[*]
|
59
|
-
|
60
|
-
vary.all? do |cache_field|
|
61
|
-
cache_field.downcase!
|
62
|
-
!original_request.headers.key?(cache_field) || request.headers[cache_field] == original_request.headers[cache_field]
|
63
|
-
end
|
64
|
-
end
|
65
|
-
|
66
|
-
def _get(request)
|
16
|
+
def get(request)
|
67
17
|
@store_mutex.synchronize do
|
68
|
-
|
69
|
-
|
70
|
-
return unless responses
|
71
|
-
|
72
|
-
responses.select! do |res|
|
73
|
-
!res.body.closed? && res.fresh?
|
74
|
-
end
|
75
|
-
|
76
|
-
responses
|
18
|
+
@store[request.response_cache_key]
|
77
19
|
end
|
78
20
|
end
|
79
21
|
|
80
|
-
def
|
22
|
+
def set(request, response)
|
81
23
|
@store_mutex.synchronize do
|
82
|
-
|
24
|
+
cached_response = @store[request.response_cache_key]
|
83
25
|
|
84
|
-
|
85
|
-
res.body.closed? || !res.fresh? || match_by_vary?(request, res)
|
86
|
-
end
|
26
|
+
cached_response.close if cached_response
|
87
27
|
|
88
|
-
|
28
|
+
@store[request.response_cache_key] = response
|
89
29
|
end
|
90
30
|
end
|
91
31
|
end
|
@@ -10,21 +10,18 @@ module HTTPX
|
|
10
10
|
module ResponseCache
|
11
11
|
CACHEABLE_VERBS = %w[GET HEAD].freeze
|
12
12
|
CACHEABLE_STATUS_CODES = [200, 203, 206, 300, 301, 410].freeze
|
13
|
+
SUPPORTED_VARY_HEADERS = %w[accept accept-encoding accept-language cookie origin].sort.freeze
|
13
14
|
private_constant :CACHEABLE_VERBS
|
14
15
|
private_constant :CACHEABLE_STATUS_CODES
|
15
16
|
|
16
17
|
class << self
|
17
18
|
def load_dependencies(*)
|
18
19
|
require_relative "response_cache/store"
|
20
|
+
require_relative "response_cache/file_store"
|
19
21
|
end
|
20
22
|
|
21
|
-
|
22
|
-
|
23
|
-
(
|
24
|
-
!request.headers.key?("cache-control") || !request.headers.get("cache-control").include?("no-store")
|
25
|
-
)
|
26
|
-
end
|
27
|
-
|
23
|
+
# whether the +response+ can be stored in the response cache.
|
24
|
+
# (i.e. has a cacheable body, does not contain directives prohibiting storage, etc...)
|
28
25
|
def cacheable_response?(response)
|
29
26
|
response.is_a?(Response) &&
|
30
27
|
(
|
@@ -39,82 +36,230 @@ module HTTPX
|
|
39
36
|
# directive prohibits caching. However, a cache that does not support
|
40
37
|
# the Range and Content-Range headers MUST NOT cache 206 (Partial
|
41
38
|
# Content) responses.
|
42
|
-
response.status != 206
|
43
|
-
response.headers.key?("etag") || response.headers.key?("last-modified") || response.fresh?
|
44
|
-
)
|
39
|
+
response.status != 206
|
45
40
|
end
|
46
41
|
|
47
|
-
|
42
|
+
# whether the +response+
|
43
|
+
def not_modified?(response)
|
48
44
|
response.is_a?(Response) && response.status == 304
|
49
45
|
end
|
50
46
|
|
51
47
|
def extra_options(options)
|
52
|
-
options.merge(
|
48
|
+
options.merge(
|
49
|
+
supported_vary_headers: SUPPORTED_VARY_HEADERS,
|
50
|
+
response_cache_store: :store,
|
51
|
+
)
|
53
52
|
end
|
54
53
|
end
|
55
54
|
|
55
|
+
# adds support for the following options:
|
56
|
+
#
|
57
|
+
# :supported_vary_headers :: array of header values that will be considered for a "vary" header based cache validation
|
58
|
+
# (defaults to {SUPPORTED_VARY_HEADERS}).
|
59
|
+
# :response_cache_store :: object where cached responses are fetch from or stored in; defaults to <tt>:store</tt> (in-memory
|
60
|
+
# cache), can be set to <tt>:file_store</tt> (file system cache store) as well, or any object which
|
61
|
+
# abides by the Cache Store Interface
|
62
|
+
#
|
63
|
+
# The Cache Store Interface requires implementation of the following methods:
|
64
|
+
#
|
65
|
+
# * +#get(request) -> response or nil+
|
66
|
+
# * +#set(request, response) -> void+
|
67
|
+
# * +#clear() -> void+)
|
68
|
+
#
|
56
69
|
module OptionsMethods
|
57
70
|
def option_response_cache_store(value)
|
58
|
-
|
71
|
+
case value
|
72
|
+
when :store
|
73
|
+
Store.new
|
74
|
+
when :file_store
|
75
|
+
FileStore.new
|
76
|
+
else
|
77
|
+
value
|
78
|
+
end
|
79
|
+
end
|
59
80
|
|
60
|
-
|
81
|
+
def option_supported_vary_headers(value)
|
82
|
+
Array(value).sort
|
61
83
|
end
|
62
84
|
end
|
63
85
|
|
64
86
|
module InstanceMethods
|
87
|
+
# wipes out all cached responses from the cache store.
|
65
88
|
def clear_response_cache
|
66
89
|
@options.response_cache_store.clear
|
67
90
|
end
|
68
91
|
|
69
92
|
def build_request(*)
|
70
93
|
request = super
|
71
|
-
return request unless
|
94
|
+
return request unless cacheable_request?(request)
|
72
95
|
|
73
|
-
|
96
|
+
prepare_cache(request)
|
74
97
|
|
75
98
|
request
|
76
99
|
end
|
77
100
|
|
101
|
+
private
|
102
|
+
|
103
|
+
def send_request(request, *)
|
104
|
+
return request if request.response
|
105
|
+
|
106
|
+
super
|
107
|
+
end
|
108
|
+
|
78
109
|
def fetch_response(request, *)
|
79
110
|
response = super
|
80
111
|
|
81
112
|
return unless response
|
82
113
|
|
83
|
-
if ResponseCache.
|
114
|
+
if ResponseCache.not_modified?(response)
|
84
115
|
log { "returning cached response for #{request.uri}" }
|
85
|
-
cached_response = @options.response_cache_store.lookup(request)
|
86
|
-
|
87
|
-
response.copy_from_cached(cached_response)
|
88
116
|
|
89
|
-
|
90
|
-
|
117
|
+
response.copy_from_cached!
|
118
|
+
elsif request.cacheable_verb? && ResponseCache.cacheable_response?(response)
|
119
|
+
request.options.response_cache_store.set(request, response) unless response.cached?
|
91
120
|
end
|
92
121
|
|
93
122
|
response
|
94
123
|
end
|
124
|
+
|
125
|
+
# will either assign a still-fresh cached response to +request+, or set up its HTTP
|
126
|
+
# cache invalidation headers in case it's not fresh anymore.
|
127
|
+
def prepare_cache(request)
|
128
|
+
cached_response = request.options.response_cache_store.get(request)
|
129
|
+
|
130
|
+
return unless cached_response && match_by_vary?(request, cached_response)
|
131
|
+
|
132
|
+
cached_response.body.rewind
|
133
|
+
|
134
|
+
if cached_response.fresh?
|
135
|
+
cached_response = cached_response.dup
|
136
|
+
cached_response.mark_as_cached!
|
137
|
+
request.response = cached_response
|
138
|
+
request.emit(:response, cached_response)
|
139
|
+
return
|
140
|
+
end
|
141
|
+
|
142
|
+
request.cached_response = cached_response
|
143
|
+
|
144
|
+
if !request.headers.key?("if-modified-since") && (last_modified = cached_response.headers["last-modified"])
|
145
|
+
request.headers.add("if-modified-since", last_modified)
|
146
|
+
end
|
147
|
+
|
148
|
+
if !request.headers.key?("if-none-match") && (etag = cached_response.headers["etag"]) # rubocop:disable Style/GuardClause
|
149
|
+
request.headers.add("if-none-match", etag)
|
150
|
+
end
|
151
|
+
end
|
152
|
+
|
153
|
+
def cacheable_request?(request)
|
154
|
+
request.cacheable_verb? &&
|
155
|
+
(
|
156
|
+
!request.headers.key?("cache-control") || !request.headers.get("cache-control").include?("no-store")
|
157
|
+
)
|
158
|
+
end
|
159
|
+
|
160
|
+
# whether the +response+ complies with the directives set by the +request+ "vary" header
|
161
|
+
# (true when none is available).
|
162
|
+
def match_by_vary?(request, response)
|
163
|
+
vary = response.vary
|
164
|
+
|
165
|
+
return true unless vary
|
166
|
+
|
167
|
+
original_request = response.original_request
|
168
|
+
|
169
|
+
if vary == %w[*]
|
170
|
+
request.options.supported_vary_headers.each do |field|
|
171
|
+
return false unless request.headers[field] == original_request.headers[field]
|
172
|
+
end
|
173
|
+
|
174
|
+
return true
|
175
|
+
end
|
176
|
+
|
177
|
+
vary.all? do |field|
|
178
|
+
!original_request.headers.key?(field) || request.headers[field] == original_request.headers[field]
|
179
|
+
end
|
180
|
+
end
|
95
181
|
end
|
96
182
|
|
97
183
|
module RequestMethods
|
184
|
+
# points to a previously cached Response corresponding to this request.
|
185
|
+
attr_accessor :cached_response
|
186
|
+
|
187
|
+
def initialize(*)
|
188
|
+
super
|
189
|
+
@cached_response = nil
|
190
|
+
end
|
191
|
+
|
192
|
+
def merge_headers(*)
|
193
|
+
super
|
194
|
+
@response_cache_key = nil
|
195
|
+
end
|
196
|
+
|
197
|
+
# returns whether this request is cacheable as per HTTP caching rules.
|
198
|
+
def cacheable_verb?
|
199
|
+
CACHEABLE_VERBS.include?(@verb)
|
200
|
+
end
|
201
|
+
|
202
|
+
# returns a unique cache key as a String identifying this request
|
98
203
|
def response_cache_key
|
99
|
-
@response_cache_key ||=
|
204
|
+
@response_cache_key ||= begin
|
205
|
+
keys = [@verb, @uri]
|
206
|
+
|
207
|
+
@options.supported_vary_headers.each do |field|
|
208
|
+
value = @headers[field]
|
209
|
+
|
210
|
+
keys << value if value
|
211
|
+
end
|
212
|
+
Digest::SHA1.hexdigest("httpx-response-cache-#{keys.join("-")}")
|
213
|
+
end
|
100
214
|
end
|
101
215
|
end
|
102
216
|
|
103
217
|
module ResponseMethods
|
104
|
-
|
218
|
+
attr_writer :original_request
|
219
|
+
|
220
|
+
def initialize(*)
|
221
|
+
super
|
222
|
+
@cached = false
|
223
|
+
end
|
224
|
+
|
225
|
+
# a copy of the request this response was originally cached from
|
226
|
+
def original_request
|
227
|
+
@original_request || @request
|
228
|
+
end
|
229
|
+
|
230
|
+
# whether this Response was duplicated from a previously {RequestMethods#cached_response}.
|
231
|
+
def cached?
|
232
|
+
@cached
|
233
|
+
end
|
234
|
+
|
235
|
+
# sets this Response as being duplicated from a previously cached response.
|
236
|
+
def mark_as_cached!
|
237
|
+
@cached = true
|
238
|
+
end
|
239
|
+
|
240
|
+
# eager-copies the response headers and body from {RequestMethods#cached_response}.
|
241
|
+
def copy_from_cached!
|
242
|
+
cached_response = @request.cached_response
|
243
|
+
|
244
|
+
return unless cached_response
|
245
|
+
|
105
246
|
# 304 responses do not have content-type, which are needed for decoding.
|
106
|
-
@headers = @headers.class.new(
|
247
|
+
@headers = @headers.class.new(cached_response.headers.merge(@headers))
|
107
248
|
|
108
|
-
@body =
|
249
|
+
@body = cached_response.body.dup
|
109
250
|
|
110
251
|
@body.rewind
|
111
252
|
end
|
112
253
|
|
113
254
|
# A response is fresh if its age has not yet exceeded its freshness lifetime.
|
255
|
+
# other (#cache_control} directives may influence the outcome, as per the rules
|
256
|
+
# from the {rfc}[https://www.rfc-editor.org/rfc/rfc7234]
|
114
257
|
def fresh?
|
115
258
|
if cache_control
|
116
259
|
return false if cache_control.include?("no-cache")
|
117
260
|
|
261
|
+
return true if cache_control.include?("immutable")
|
262
|
+
|
118
263
|
# check age: max-age
|
119
264
|
max_age = cache_control.find { |directive| directive.start_with?("s-maxage") }
|
120
265
|
|
@@ -132,15 +277,16 @@ module HTTPX
|
|
132
277
|
begin
|
133
278
|
expires = Time.httpdate(@headers["expires"])
|
134
279
|
rescue ArgumentError
|
135
|
-
return
|
280
|
+
return false
|
136
281
|
end
|
137
282
|
|
138
283
|
return (expires - Time.now).to_i.positive?
|
139
284
|
end
|
140
285
|
|
141
|
-
|
286
|
+
false
|
142
287
|
end
|
143
288
|
|
289
|
+
# returns the "cache-control" directives as an Array of String(s).
|
144
290
|
def cache_control
|
145
291
|
return @cache_control if defined?(@cache_control)
|
146
292
|
|
@@ -151,24 +297,28 @@ module HTTPX
|
|
151
297
|
end
|
152
298
|
end
|
153
299
|
|
300
|
+
# returns the "vary" header value as an Array of (String) headers.
|
154
301
|
def vary
|
155
302
|
return @vary if defined?(@vary)
|
156
303
|
|
157
304
|
@vary = begin
|
158
305
|
return unless @headers.key?("vary")
|
159
306
|
|
160
|
-
@headers["vary"].split(/ *, */)
|
307
|
+
@headers["vary"].split(/ *, */).map(&:downcase)
|
161
308
|
end
|
162
309
|
end
|
163
310
|
|
164
311
|
private
|
165
312
|
|
313
|
+
# returns the value of the "age" header as an Integer (time since epoch).
|
314
|
+
# if no "age" of header exists, it returns the number of seconds since {#date}.
|
166
315
|
def age
|
167
316
|
return @headers["age"].to_i if @headers.key?("age")
|
168
317
|
|
169
318
|
(Time.now - date).to_i
|
170
319
|
end
|
171
320
|
|
321
|
+
# returns the value of the "date" header as a Time object
|
172
322
|
def date
|
173
323
|
@date ||= Time.httpdate(@headers["date"])
|
174
324
|
rescue NoMethodError, ArgumentError
|
@@ -17,7 +17,9 @@ module HTTPX
|
|
17
17
|
# TODO: pass max_retries in a configure/load block
|
18
18
|
|
19
19
|
IDEMPOTENT_METHODS = %w[GET OPTIONS HEAD PUT DELETE].freeze
|
20
|
-
|
20
|
+
|
21
|
+
# subset of retryable errors which are safe to retry when reconnecting
|
22
|
+
RECONNECTABLE_ERRORS = [
|
21
23
|
IOError,
|
22
24
|
EOFError,
|
23
25
|
Errno::ECONNRESET,
|
@@ -25,12 +27,15 @@ module HTTPX
|
|
25
27
|
Errno::EPIPE,
|
26
28
|
Errno::EINVAL,
|
27
29
|
Errno::ETIMEDOUT,
|
28
|
-
Parser::Error,
|
29
|
-
TLSError,
|
30
|
-
TimeoutError,
|
31
30
|
ConnectionError,
|
32
|
-
|
31
|
+
TLSError,
|
32
|
+
Connection::HTTP2::Error,
|
33
33
|
].freeze
|
34
|
+
|
35
|
+
RETRYABLE_ERRORS = (RECONNECTABLE_ERRORS + [
|
36
|
+
Parser::Error,
|
37
|
+
TimeoutError,
|
38
|
+
]).freeze
|
34
39
|
DEFAULT_JITTER = ->(interval) { interval * ((rand + 1) * 0.5) }
|
35
40
|
|
36
41
|
if ENV.key?("HTTPX_NO_JITTER")
|
@@ -88,6 +93,7 @@ module HTTPX
|
|
88
93
|
end
|
89
94
|
|
90
95
|
module InstanceMethods
|
96
|
+
# returns a `:retries` plugin enabled session with +n+ maximum retries per request setting.
|
91
97
|
def max_retries(n)
|
92
98
|
with(max_retries: n)
|
93
99
|
end
|
@@ -99,16 +105,16 @@ module HTTPX
|
|
99
105
|
|
100
106
|
if response &&
|
101
107
|
request.retries.positive? &&
|
102
|
-
|
108
|
+
repeatable_request?(request, options) &&
|
103
109
|
(
|
104
110
|
(
|
105
|
-
response.is_a?(ErrorResponse) &&
|
111
|
+
response.is_a?(ErrorResponse) && retryable_error?(response.error)
|
106
112
|
) ||
|
107
113
|
(
|
108
114
|
options.retry_on && options.retry_on.call(response)
|
109
115
|
)
|
110
116
|
)
|
111
|
-
|
117
|
+
try_partial_retry(request, response)
|
112
118
|
log { "failed to get response, #{request.retries} tries to go..." }
|
113
119
|
request.retries -= 1 unless request.ping? # do not exhaust retries on connection liveness probes
|
114
120
|
request.transition(:idle)
|
@@ -125,9 +131,10 @@ module HTTPX
|
|
125
131
|
retry_start = Utils.now
|
126
132
|
log { "retrying after #{retry_after} secs..." }
|
127
133
|
selector.after(retry_after) do
|
128
|
-
if request.response
|
134
|
+
if (response = request.response)
|
135
|
+
response.finish!
|
129
136
|
# request has terminated abruptly meanwhile
|
130
|
-
request.emit(:response,
|
137
|
+
request.emit(:response, response)
|
131
138
|
else
|
132
139
|
log { "retrying (elapsed time: #{Utils.elapsed_time(retry_start)})!!" }
|
133
140
|
send_request(request, selector, options)
|
@@ -142,11 +149,13 @@ module HTTPX
|
|
142
149
|
response
|
143
150
|
end
|
144
151
|
|
145
|
-
|
152
|
+
# returns whether +request+ can be retried.
|
153
|
+
def repeatable_request?(request, options)
|
146
154
|
IDEMPOTENT_METHODS.include?(request.verb) || options.retry_change_requests
|
147
155
|
end
|
148
156
|
|
149
|
-
|
157
|
+
# returns whether the +ex+ exception happend for a retriable request.
|
158
|
+
def retryable_error?(ex)
|
150
159
|
RETRYABLE_ERRORS.any? { |klass| ex.is_a?(klass) }
|
151
160
|
end
|
152
161
|
|
@@ -155,11 +164,11 @@ module HTTPX
|
|
155
164
|
end
|
156
165
|
|
157
166
|
#
|
158
|
-
#
|
167
|
+
# Attempt to set the request to perform a partial range request.
|
159
168
|
# This happens if the peer server accepts byte-range requests, and
|
160
169
|
# the last response contains some body payload.
|
161
170
|
#
|
162
|
-
def
|
171
|
+
def try_partial_retry(request, response)
|
163
172
|
response = response.response if response.is_a?(ErrorResponse)
|
164
173
|
|
165
174
|
return unless response
|
@@ -180,10 +189,13 @@ module HTTPX
|
|
180
189
|
end
|
181
190
|
|
182
191
|
module RequestMethods
|
192
|
+
# number of retries left.
|
183
193
|
attr_accessor :retries
|
184
194
|
|
195
|
+
# a response partially received before.
|
185
196
|
attr_writer :partial_response
|
186
197
|
|
198
|
+
# initializes the request instance, sets the number of retries for the request.
|
187
199
|
def initialize(*args)
|
188
200
|
super
|
189
201
|
@retries = @options.max_retries
|