withruntime 0.1.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 +7 -0
- data/GUIDE.md +406 -0
- data/LICENSE +55 -0
- data/lib/withruntime/client.rb +79 -0
- data/lib/withruntime/credentials.rb +85 -0
- data/lib/withruntime/errors.rb +114 -0
- data/lib/withruntime/fields.rb +40 -0
- data/lib/withruntime/file_watch.rb +73 -0
- data/lib/withruntime/images.rb +258 -0
- data/lib/withruntime/network_products.rb +49 -0
- data/lib/withruntime/parity_products.rb +51 -0
- data/lib/withruntime/products.rb +373 -0
- data/lib/withruntime/record.rb +85 -0
- data/lib/withruntime/sandbox.rb +618 -0
- data/lib/withruntime/tar.rb +107 -0
- data/lib/withruntime/transport.rb +362 -0
- data/lib/withruntime/tunnel.rb +275 -0
- data/lib/withruntime/version.rb +5 -0
- data/lib/withruntime/websocket.rb +249 -0
- data/lib/withruntime.rb +32 -0
- metadata +64 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "stringio"
|
|
4
|
+
require "zlib"
|
|
5
|
+
|
|
6
|
+
module WithRuntime
|
|
7
|
+
# A small ustar writer and reader for directory uploads, downloads and build contexts.
|
|
8
|
+
module Tar
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def header(name, size, mode, type, mtime = 0)
|
|
12
|
+
bytes = name.b
|
|
13
|
+
prefix = "".b
|
|
14
|
+
if bytes.bytesize > 100
|
|
15
|
+
split = name.rindex("/", 155)
|
|
16
|
+
raise ArgumentError, "Path too long for a tar archive: #{name}" if split.nil? || split <= 0 || name[(split + 1)..].bytesize > 100
|
|
17
|
+
|
|
18
|
+
prefix = name[0, split].b
|
|
19
|
+
bytes = name[(split + 1)..].b
|
|
20
|
+
end
|
|
21
|
+
block = "\0".b * 512
|
|
22
|
+
put = ->(offset, value, length) { block[offset, [length, value.bytesize].min] = value.b[0, length] }
|
|
23
|
+
octal = ->(value, length) { "#{value.to_s(8).rjust(length - 1, "0")}\0" }
|
|
24
|
+
put.call(0, bytes, 100)
|
|
25
|
+
put.call(100, octal.call(mode & 0o7777, 8), 8)
|
|
26
|
+
put.call(108, octal.call(0, 8), 8)
|
|
27
|
+
put.call(116, octal.call(0, 8), 8)
|
|
28
|
+
put.call(124, octal.call(size, 12), 12)
|
|
29
|
+
put.call(136, octal.call(mtime, 12), 12)
|
|
30
|
+
put.call(148, " " * 8, 8)
|
|
31
|
+
put.call(156, type, 1)
|
|
32
|
+
put.call(257, "ustar\u000000", 8)
|
|
33
|
+
put.call(345, prefix, 155)
|
|
34
|
+
put.call(148, "#{block.sum(64).to_s(8).rjust(6, "0")}\0 ", 8)
|
|
35
|
+
block
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def pad(size) = "\0".b * ((512 - (size % 512)) % 512)
|
|
39
|
+
|
|
40
|
+
def gzip(data)
|
|
41
|
+
out = StringIO.new("".b)
|
|
42
|
+
writer = Zlib::GzipWriter.new(out, 6)
|
|
43
|
+
writer.mtime = 0
|
|
44
|
+
writer.write(data)
|
|
45
|
+
writer.close
|
|
46
|
+
out.string
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# A directory as a gzipped tar: directories and regular files; links stay behind.
|
|
50
|
+
def pack_directory(root)
|
|
51
|
+
tar = "".b
|
|
52
|
+
base = File.expand_path(root)
|
|
53
|
+
Dir.glob("**/*", File::FNM_DOTMATCH, base: base).sort.each do |relative|
|
|
54
|
+
next if relative.split("/").any? { |part| [".", ".."].include?(part) }
|
|
55
|
+
|
|
56
|
+
full = File.join(base, relative)
|
|
57
|
+
stat = File.lstat(full)
|
|
58
|
+
if stat.directory?
|
|
59
|
+
tar << header("#{relative}/", 0, stat.mode, "5", stat.mtime.to_i)
|
|
60
|
+
elsif stat.file?
|
|
61
|
+
data = File.binread(full)
|
|
62
|
+
tar << header(relative, data.bytesize, stat.mode, "0", stat.mtime.to_i) << data << pad(data.bytesize)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
gzip(tar << ("\0".b * 1024))
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Unpacks a gzipped tar into target, refusing any entry that would land outside it, and links.
|
|
69
|
+
def unpack(archive, target)
|
|
70
|
+
data = Zlib::GzipReader.new(StringIO.new(archive)).read.b
|
|
71
|
+
root = File.expand_path(target)
|
|
72
|
+
FileUtils.mkdir_p(root)
|
|
73
|
+
field = ->(start, length) { data[start, length].split("\0", 2).first.to_s.force_encoding("UTF-8") }
|
|
74
|
+
offset = 0
|
|
75
|
+
long_name = nil
|
|
76
|
+
while offset + 512 <= data.bytesize
|
|
77
|
+
break if data[offset, 512].count("\0") == 512
|
|
78
|
+
|
|
79
|
+
size = field.call(offset + 124, 12).strip.then { |text| text.empty? ? 0 : text.to_i(8) }
|
|
80
|
+
type = data[offset + 156]
|
|
81
|
+
prefix = field.call(offset + 345, 155)
|
|
82
|
+
name = long_name || (prefix.empty? ? field.call(offset, 100) : "#{prefix}/#{field.call(offset, 100)}")
|
|
83
|
+
long_name = nil
|
|
84
|
+
mode = field.call(offset + 100, 8).strip.then { |text| text.empty? ? 0o644 : text.to_i(8) }
|
|
85
|
+
body = data[offset + 512, size]
|
|
86
|
+
offset += 512 + (((size + 511) / 512) * 512)
|
|
87
|
+
if type == "L"
|
|
88
|
+
long_name = body.split("\0", 2).first.force_encoding("UTF-8")
|
|
89
|
+
next
|
|
90
|
+
end
|
|
91
|
+
name = name.delete_prefix("./")
|
|
92
|
+
next if name.empty? || name == "." || name == "./"
|
|
93
|
+
|
|
94
|
+
destination = File.expand_path(name, root)
|
|
95
|
+
raise IOError, "Refusing an archive entry outside the target: #{name}" unless destination.start_with?("#{root}/")
|
|
96
|
+
|
|
97
|
+
case type
|
|
98
|
+
when "5" then FileUtils.mkdir_p(destination)
|
|
99
|
+
when "0", "\0", "7"
|
|
100
|
+
FileUtils.mkdir_p(File.dirname(destination))
|
|
101
|
+
File.binwrite(destination, body)
|
|
102
|
+
File.chmod(mode & 0o777, destination)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
module WithRuntime
|
|
9
|
+
# One connection pool, one retry policy and one error shape for every
|
|
10
|
+
# product: the same rules as the JavaScript, Python, Go and Java SDKs. Writes
|
|
11
|
+
# carry an idempotency key, made per call and kept across the client's own
|
|
12
|
+
# retries; transport failures, 429, 502, 503 and 504 are retried with backoff
|
|
13
|
+
# and jitter; a create waits out a full trial, quota or region.
|
|
14
|
+
class Transport
|
|
15
|
+
class OpeningCancelled < IOError; end
|
|
16
|
+
class Cancellation
|
|
17
|
+
def initialize
|
|
18
|
+
@lock = Mutex.new
|
|
19
|
+
@cancelled = false
|
|
20
|
+
@action = nil
|
|
21
|
+
@changed = ConditionVariable.new
|
|
22
|
+
end
|
|
23
|
+
def cancelled? = @lock.synchronize { @cancelled }
|
|
24
|
+
def register(&action)
|
|
25
|
+
run = @lock.synchronize { @action = action; @cancelled }
|
|
26
|
+
action.call if run
|
|
27
|
+
end
|
|
28
|
+
def wait(seconds)
|
|
29
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + seconds
|
|
30
|
+
@lock.synchronize do
|
|
31
|
+
until @cancelled
|
|
32
|
+
left = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
33
|
+
return true if left <= 0
|
|
34
|
+
@changed.wait(@lock, left)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
false
|
|
38
|
+
end
|
|
39
|
+
def cancel
|
|
40
|
+
action = @lock.synchronize { @cancelled = true; @changed.broadcast; @action }
|
|
41
|
+
action&.call
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
NETWORK_ERRORS = [IOError, SystemCallError, SocketError, Timeout::Error, OpenSSL::SSL::SSLError,
|
|
45
|
+
Net::HTTPBadResponse, Net::ProtocolError, EOFError].freeze
|
|
46
|
+
|
|
47
|
+
attr_reader :base_url, :api_key, :timeout, :max_retries, :wait_for_capacity
|
|
48
|
+
|
|
49
|
+
def initialize(api_key:, base_url:, timeout:, max_retries:, max_connections:, wait_for_capacity:)
|
|
50
|
+
@api_key = api_key
|
|
51
|
+
@base_url = base_url
|
|
52
|
+
@uri = URI(base_url)
|
|
53
|
+
@timeout = timeout
|
|
54
|
+
@max_retries = max_retries
|
|
55
|
+
@wait_for_capacity = wait_for_capacity
|
|
56
|
+
@limit = [1, max_connections].max
|
|
57
|
+
@idle = []
|
|
58
|
+
@open = 0
|
|
59
|
+
@lock = Mutex.new
|
|
60
|
+
@freed = ConditionVariable.new
|
|
61
|
+
@client = "sdk-ruby/#{VERSION}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Percent-encodes one path segment, as encodeURIComponent does.
|
|
65
|
+
def self.segment(value)
|
|
66
|
+
value.to_s.b.gsub(/[^A-Za-z0-9\-_.!~*'()]/) { |c| format("%%%02X", c.ord) }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The value of a JSON call: a Hash, an Array or nil.
|
|
70
|
+
def json(method, path, query: nil, body: nil, **options)
|
|
71
|
+
with_connection(options) do |http|
|
|
72
|
+
response = send_call(http, method, path, query: query, body: body, **options)
|
|
73
|
+
text = response.body.to_s
|
|
74
|
+
text.strip.empty? ? nil : JSON.parse(text)
|
|
75
|
+
rescue JSON::ParserError => e
|
|
76
|
+
raise Error.new("Unexpected answer from #{method} #{path}: #{e.message}", code: "unexpected_answer")
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def bytes(method, path, query: nil, body: nil, **options)
|
|
81
|
+
with_connection(options) do |http|
|
|
82
|
+
send_call(http, method, path, query: query, body: body, **options).body.to_s.b
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Newline-delimited JSON events as they arrive, one Hash per event, on a
|
|
87
|
+
# connection of their own.
|
|
88
|
+
def events(method, path, query: nil, body: nil, cancel: nil, **options, &block)
|
|
89
|
+
return enum_for(:events, method, path, query: query, body: body, cancel: cancel, **options) unless block
|
|
90
|
+
|
|
91
|
+
cancellation = Cancellation.new
|
|
92
|
+
cancel&.register { cancellation.cancel }
|
|
93
|
+
http = connect
|
|
94
|
+
begin
|
|
95
|
+
buffer = +""
|
|
96
|
+
send_call(http, method, path, query: query, body: body, accept: "application/x-ndjson", cancel: cancellation, **options) do |chunk|
|
|
97
|
+
buffer << chunk
|
|
98
|
+
while (newline = buffer.index("\n"))
|
|
99
|
+
line = buffer.slice!(0, newline + 1).strip
|
|
100
|
+
yield JSON.parse(line) unless line.empty?
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
yield JSON.parse(buffer) unless buffer.strip.empty?
|
|
104
|
+
rescue JSON::ParserError => e
|
|
105
|
+
raise Error.new("Unexpected stream line: #{e.message}", code: "unexpected_answer")
|
|
106
|
+
ensure
|
|
107
|
+
cancellation.cancel
|
|
108
|
+
begin
|
|
109
|
+
http.finish if http.started?
|
|
110
|
+
rescue IOError
|
|
111
|
+
nil
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def connection_error(timed_out, write, key, cause = nil)
|
|
117
|
+
if timed_out
|
|
118
|
+
ConnectionError.new("The call was cancelled or ran past its deadline.", code: "timeout",
|
|
119
|
+
hint: "Allow a longer timeout for a long call, or retry with the same idempotency key.",
|
|
120
|
+
idempotency_key: key)
|
|
121
|
+
else
|
|
122
|
+
message = "No answer from Runtime at #{base_url}."
|
|
123
|
+
message += " The change may have happened; retrying with the same idempotency key is safe." if write
|
|
124
|
+
message += " (#{cause.class}: #{cause.message})" if cause
|
|
125
|
+
ConnectionError.new(message, code: "connection_error",
|
|
126
|
+
hint: "Check the network, HTTPS_PROXY, and RUNTIME_API_URL if you set it.",
|
|
127
|
+
idempotency_key: key)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
private
|
|
132
|
+
|
|
133
|
+
# HTTPS_PROXY (or HTTP_PROXY for an http origin) and NO_PROXY, read the
|
|
134
|
+
# way curl reads them. Net::HTTP's own :ENV reads http_proxy even for HTTPS.
|
|
135
|
+
def self.proxy_for(uri, env = ENV)
|
|
136
|
+
names = uri.scheme == "https" ? %w[HTTPS_PROXY https_proxy] : %w[HTTP_PROXY http_proxy]
|
|
137
|
+
value = names.map { |name| env[name] }.find { |found| found && !found.strip.empty? }
|
|
138
|
+
return nil unless value
|
|
139
|
+
|
|
140
|
+
# URI's matcher knows neither curl's "*" (no proxy at all) nor "*.example.com".
|
|
141
|
+
exempt = (env["NO_PROXY"] || env["no_proxy"]).to_s.split(",").map(&:strip).reject(&:empty?)
|
|
142
|
+
return nil if exempt.include?("*")
|
|
143
|
+
exempt = exempt.map { |entry| entry.delete_prefix("*") }.join(",")
|
|
144
|
+
return nil if !exempt.empty? && !URI::Generic.use_proxy?(uri.host, uri.host, uri.port, exempt)
|
|
145
|
+
|
|
146
|
+
value = "http://#{value}" unless value.include?("://")
|
|
147
|
+
URI(value.strip)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def connect
|
|
151
|
+
proxy = Transport.proxy_for(@uri)
|
|
152
|
+
http = if proxy
|
|
153
|
+
Net::HTTP.new(@uri.host, @uri.port, proxy.host, proxy.port,
|
|
154
|
+
proxy.user && URI.decode_www_form_component(proxy.user),
|
|
155
|
+
proxy.password && URI.decode_www_form_component(proxy.password))
|
|
156
|
+
else
|
|
157
|
+
Net::HTTP.new(@uri.host, @uri.port, nil)
|
|
158
|
+
end
|
|
159
|
+
http.use_ssl = @uri.scheme == "https"
|
|
160
|
+
http.max_retries = 0
|
|
161
|
+
http.open_timeout = 30
|
|
162
|
+
http.keep_alive_timeout = 30
|
|
163
|
+
http
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Net::HTTP keeps its socket local until TCP, proxy CONNECT and TLS finish.
|
|
167
|
+
# Keep that opening alone in an owned worker: raising an IOError there runs
|
|
168
|
+
# Net::HTTP's connect rescue and closes its local socket. Never interrupt the
|
|
169
|
+
# caller's event block, and join before returning so cancellation leaks no
|
|
170
|
+
# opening thread or socket.
|
|
171
|
+
def open_connection(http, cancel)
|
|
172
|
+
return if http.started?
|
|
173
|
+
return http.start unless cancel
|
|
174
|
+
|
|
175
|
+
opened = false
|
|
176
|
+
gate = Queue.new
|
|
177
|
+
worker = Thread.new do
|
|
178
|
+
Thread.current.report_on_exception = false
|
|
179
|
+
gate.pop
|
|
180
|
+
http.start
|
|
181
|
+
end
|
|
182
|
+
worker.report_on_exception = false
|
|
183
|
+
cancel.register { worker.raise(OpeningCancelled, "Stream connection cancelled.") if worker.alive? }
|
|
184
|
+
gate.push(true)
|
|
185
|
+
worker.value
|
|
186
|
+
opened = true
|
|
187
|
+
ensure
|
|
188
|
+
if worker&.alive?
|
|
189
|
+
worker.raise(OpeningCancelled, "Stream connection cancelled.")
|
|
190
|
+
begin
|
|
191
|
+
worker.join
|
|
192
|
+
rescue *NETWORK_ERRORS
|
|
193
|
+
nil
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
# connect can assign @socket immediately before do_start marks the
|
|
197
|
+
# session started. An interruption between those lines skips connect's
|
|
198
|
+
# rescue, so finish/started? alone cannot clean up this failed opening.
|
|
199
|
+
if worker && !opened
|
|
200
|
+
begin
|
|
201
|
+
http.instance_variable_get(:@socket)&.close
|
|
202
|
+
rescue IOError, SystemCallError
|
|
203
|
+
nil
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def with_connection(_options)
|
|
209
|
+
http = @lock.synchronize do
|
|
210
|
+
loop do
|
|
211
|
+
break @idle.pop unless @idle.empty?
|
|
212
|
+
if @open < @limit
|
|
213
|
+
@open += 1
|
|
214
|
+
break connect
|
|
215
|
+
end
|
|
216
|
+
@freed.wait(@lock)
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
healthy = false
|
|
220
|
+
begin
|
|
221
|
+
result = yield http
|
|
222
|
+
healthy = true
|
|
223
|
+
result
|
|
224
|
+
ensure
|
|
225
|
+
@lock.synchronize do
|
|
226
|
+
if healthy
|
|
227
|
+
@idle.push(http)
|
|
228
|
+
else
|
|
229
|
+
http.finish if http.started?
|
|
230
|
+
@open -= 1
|
|
231
|
+
end
|
|
232
|
+
@freed.signal
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def backoff(attempt) = [8.0, 0.25 * (2**[attempt, 10].min)].min * (0.5 + rand)
|
|
238
|
+
|
|
239
|
+
# 0.5 s, 1 s, 2 s, 4 s, then every 8 s, jittered, so a queue of CI jobs spreads out.
|
|
240
|
+
def room_backoff(attempt) = [8.0, 0.5 * (2**[attempt, 10].min)].min * (0.75 + (rand * 0.5))
|
|
241
|
+
|
|
242
|
+
def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
243
|
+
|
|
244
|
+
def target(path, query)
|
|
245
|
+
pairs = []
|
|
246
|
+
(query || {}).each do |key, value|
|
|
247
|
+
next if value.nil?
|
|
248
|
+
|
|
249
|
+
Array(value).each { |item| pairs << [key.to_s, item.to_s] }
|
|
250
|
+
end
|
|
251
|
+
pairs.empty? ? path : "#{path}?#{URI.encode_www_form(pairs)}"
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Sends a call, retrying as the rules say, and returns the response once it
|
|
255
|
+
# succeeded. With a block, the body is streamed to it.
|
|
256
|
+
def send_call(http, method, path, query: nil, body: nil, raw: nil, accept: "application/json", wait: nil,
|
|
257
|
+
key: nil, no_retry: false, room: 0, timeout: nil, cancel: nil, &stream)
|
|
258
|
+
write = method != "GET"
|
|
259
|
+
key = write ? (key || SecureRandom.uuid) : nil
|
|
260
|
+
deadline = now + (timeout || @timeout) + room
|
|
261
|
+
room_until = now + room
|
|
262
|
+
room_attempt = 0
|
|
263
|
+
payload = raw || (body.nil? ? nil : JSON.generate(body))
|
|
264
|
+
attempt = 0
|
|
265
|
+
loop do
|
|
266
|
+
raise connection_error(true, write, key) if cancel&.cancelled?
|
|
267
|
+
left = deadline - now
|
|
268
|
+
raise connection_error(true, write, key) if left <= 0
|
|
269
|
+
|
|
270
|
+
request = Net::HTTPGenericRequest.new(method, !payload.nil?, true, target(path, query))
|
|
271
|
+
request["Authorization"] = "Bearer #{api_key}"
|
|
272
|
+
request["Accept"] = accept
|
|
273
|
+
request["X-Runtime-Client"] = @client
|
|
274
|
+
request["User-Agent"] = "withruntime-ruby/#{VERSION}"
|
|
275
|
+
request["Idempotency-Key"] = key if key
|
|
276
|
+
request["Prefer"] = "wait=#{[120, wait].min}" if wait&.positive?
|
|
277
|
+
if payload
|
|
278
|
+
request["Content-Type"] = raw ? "application/octet-stream" : "application/json"
|
|
279
|
+
request.body = payload
|
|
280
|
+
end
|
|
281
|
+
http.read_timeout = left
|
|
282
|
+
http.write_timeout = left if http.respond_to?(:write_timeout=)
|
|
283
|
+
response = nil
|
|
284
|
+
begin
|
|
285
|
+
open_connection(http, cancel)
|
|
286
|
+
cancel&.register do
|
|
287
|
+
http.read_timeout = 0.001
|
|
288
|
+
http.write_timeout = 0.001
|
|
289
|
+
http.open_timeout = 0.001
|
|
290
|
+
begin
|
|
291
|
+
http.instance_variable_get(:@socket)&.io&.close
|
|
292
|
+
rescue IOError, SystemCallError
|
|
293
|
+
nil
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
raise connection_error(true, write, key) if cancel&.cancelled?
|
|
297
|
+
if stream
|
|
298
|
+
http.request(request) do |answer|
|
|
299
|
+
response = answer
|
|
300
|
+
answer.read_body(&stream) if answer.code.to_i < 300
|
|
301
|
+
end
|
|
302
|
+
else
|
|
303
|
+
response = http.request(request)
|
|
304
|
+
end
|
|
305
|
+
status = response.code.to_i
|
|
306
|
+
if status >= 300 && status < 400
|
|
307
|
+
raise Net::HTTPBadResponse, "Runtime answered a redirect (#{status})"
|
|
308
|
+
end
|
|
309
|
+
rescue *NETWORK_ERRORS => e
|
|
310
|
+
http.finish if http.started?
|
|
311
|
+
late = e.is_a?(Timeout::Error) || now >= deadline || cancel&.cancelled?
|
|
312
|
+
raise connection_error(late, write, key, e) if late || no_retry || attempt >= max_retries
|
|
313
|
+
|
|
314
|
+
pause(backoff(attempt), deadline, write, key, nil, cancel)
|
|
315
|
+
attempt += 1
|
|
316
|
+
next
|
|
317
|
+
end
|
|
318
|
+
return response if status < 300
|
|
319
|
+
|
|
320
|
+
text = response.body.to_s
|
|
321
|
+
parsed = begin
|
|
322
|
+
JSON.parse(text)
|
|
323
|
+
rescue JSON::ParserError
|
|
324
|
+
{ "error" => { "message" => text.strip.empty? ? nil : text.strip[0, 500] }.compact }
|
|
325
|
+
end
|
|
326
|
+
failure = Error.from(status, parsed, key)
|
|
327
|
+
if !no_retry && room.positive? && Error::WAITS_FOR_ROOM.include?(failure.code) && failure.details["field"] != "count"
|
|
328
|
+
room_left = room_until - now
|
|
329
|
+
raise failure if room_left <= 0
|
|
330
|
+
|
|
331
|
+
wait_for = failure.retry_after || room_backoff(room_attempt)
|
|
332
|
+
room_attempt += 1
|
|
333
|
+
pause([room_left, wait_for].min, deadline, write, key, failure, cancel)
|
|
334
|
+
next
|
|
335
|
+
end
|
|
336
|
+
retryable = [429, 502, 503, 504].include?(status) && !Error::DELIBERATE.include?(failure.code)
|
|
337
|
+
raise failure if no_retry || !retryable || attempt >= max_retries
|
|
338
|
+
|
|
339
|
+
wait_for = failure.retry_after
|
|
340
|
+
if wait_for.nil?
|
|
341
|
+
header = Float(response["Retry-After"], exception: false)
|
|
342
|
+
wait_for = header&.positive? ? header : backoff(attempt)
|
|
343
|
+
end
|
|
344
|
+
pause([30.0, wait_for].min * (0.9 + (rand * 0.2)), deadline, write, key, failure, cancel)
|
|
345
|
+
attempt += 1
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def pause(seconds, deadline, write, key, failure = nil, cancel = nil)
|
|
350
|
+
if now + seconds >= deadline
|
|
351
|
+
raise failure if failure
|
|
352
|
+
|
|
353
|
+
raise connection_error(true, write, key)
|
|
354
|
+
end
|
|
355
|
+
if cancel
|
|
356
|
+
raise connection_error(true, write, key) unless cancel.wait(seconds)
|
|
357
|
+
else
|
|
358
|
+
sleep(seconds)
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
end
|