withruntime 0.1.0 → 0.2.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/lib/withruntime/api_defaults.rb +17 -0
- data/lib/withruntime/client.rb +43 -9
- data/lib/withruntime/credentials.rb +31 -5
- data/lib/withruntime/errors.rb +9 -4
- data/lib/withruntime/network_products.rb +9 -9
- data/lib/withruntime/parity_products.rb +1 -1
- data/lib/withruntime/products.rb +8 -1
- data/lib/withruntime/sandbox.rb +150 -64
- data/lib/withruntime/tar.rb +218 -33
- data/lib/withruntime/transport.rb +189 -16
- data/lib/withruntime/version.rb +1 -1
- data/lib/withruntime/wallet.rb +68 -0
- data/lib/withruntime.rb +5 -2
- metadata +9 -4
- data/GUIDE.md +0 -406
data/lib/withruntime/tar.rb
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "fileutils"
|
|
3
4
|
require "stringio"
|
|
5
|
+
require "tmpdir"
|
|
4
6
|
require "zlib"
|
|
5
7
|
|
|
6
8
|
module WithRuntime
|
|
@@ -65,41 +67,224 @@ module WithRuntime
|
|
|
65
67
|
gzip(tar << ("\0".b * 1024))
|
|
66
68
|
end
|
|
67
69
|
|
|
68
|
-
#
|
|
70
|
+
# A folder that did not arrive whole: tar in the sandbox stopped part way
|
|
71
|
+
# (a file it may not read, one that changed as it was read), which ends
|
|
72
|
+
# the gzip stream short, or the connection was lost.
|
|
73
|
+
def cut_short
|
|
74
|
+
Error.new("The folder's archive arrived cut short: tar in the sandbox stopped part way, or the " \
|
|
75
|
+
"connection was lost. Nothing was written.",
|
|
76
|
+
code: "download_incomplete",
|
|
77
|
+
hint: "Try again. If it fails the same way, a file in the folder cannot be read by the " \
|
|
78
|
+
"sandbox user or changes as it is read.")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Unpacks a whole gzipped tar held in memory into target, by Unpacker's rules.
|
|
69
82
|
def unpack(archive, target)
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
83
|
+
unpacker = Unpacker.new(target)
|
|
84
|
+
begin
|
|
85
|
+
unpacker.feed(archive)
|
|
86
|
+
unpacker.finish
|
|
87
|
+
ensure
|
|
88
|
+
unpacker.discard
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Unpacks a gzipped tar fed to it a chunk at a time into a target folder,
|
|
93
|
+
# holding no more than a chunk. Entries land only inside the target, and
|
|
94
|
+
# symbolic links are not made; a hard link lands as the file it names. It unpacks into a folder beside the target and moves
|
|
95
|
+
# it into place only once the whole archive arrived (+finish+), so an
|
|
96
|
+
# archive cut short leaves nothing behind that could pass for the folder.
|
|
97
|
+
# A target that exists is merged into, files of the same name replaced,
|
|
98
|
+
# never through a link in it. +discard+ removes what was unpacked if
|
|
99
|
+
# +finish+ was never reached.
|
|
100
|
+
class Unpacker
|
|
101
|
+
def initialize(target)
|
|
102
|
+
@final = File.expand_path(target)
|
|
103
|
+
FileUtils.mkdir_p(File.dirname(@final))
|
|
104
|
+
@staging = Dir.mktmpdir("#{File.basename(@final)}.runtime-partial-", File.dirname(@final))
|
|
105
|
+
@root = File.realpath(@staging)
|
|
106
|
+
@inflate = Zlib::Inflate.new(Zlib::MAX_WBITS + 16)
|
|
107
|
+
@held = "".b
|
|
108
|
+
@entry = nil
|
|
109
|
+
@left = 0
|
|
110
|
+
@padding = 0
|
|
111
|
+
@long = nil
|
|
112
|
+
@long_link = nil
|
|
113
|
+
@ended = false
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def feed(data)
|
|
117
|
+
begin
|
|
118
|
+
@held << @inflate.inflate(data)
|
|
119
|
+
rescue Zlib::Error
|
|
120
|
+
raise Tar.cut_short
|
|
121
|
+
end
|
|
122
|
+
nil while step
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Checks the archive arrived whole, then puts it in place.
|
|
126
|
+
def finish
|
|
127
|
+
raise Tar.cut_short unless @inflate.finished? && @ended
|
|
128
|
+
|
|
129
|
+
if File.exist?(@final) || File.symlink?(@final)
|
|
130
|
+
final = File.realpath(@final)
|
|
131
|
+
merge(@root, final, final)
|
|
132
|
+
else
|
|
133
|
+
File.rename(@staging, @final)
|
|
90
134
|
end
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def discard
|
|
138
|
+
@entry[:file]&.close if @entry.is_a?(Hash)
|
|
139
|
+
FileUtils.rm_rf(@staging)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
private
|
|
143
|
+
|
|
144
|
+
def step
|
|
145
|
+
if @ended
|
|
146
|
+
@held.clear
|
|
147
|
+
return false
|
|
148
|
+
end
|
|
149
|
+
if @entry.nil?
|
|
150
|
+
return false if @held.bytesize < 512
|
|
151
|
+
|
|
152
|
+
header = @held.slice!(0, 512)
|
|
153
|
+
if header.count("\0") == 512
|
|
154
|
+
@ended = true
|
|
155
|
+
else
|
|
156
|
+
begin_entry(header)
|
|
157
|
+
end
|
|
158
|
+
return true
|
|
159
|
+
end
|
|
160
|
+
if @left.positive?
|
|
161
|
+
return false if @held.empty?
|
|
162
|
+
|
|
163
|
+
part = @held.slice!(0, @left)
|
|
164
|
+
@left -= part.bytesize
|
|
165
|
+
@entry[:file]&.write(part)
|
|
166
|
+
@entry[:long]&.<<(part)
|
|
167
|
+
return false if @left.positive?
|
|
168
|
+
|
|
169
|
+
end_body
|
|
170
|
+
return true
|
|
171
|
+
end
|
|
172
|
+
if @padding.positive?
|
|
173
|
+
return false if @held.empty?
|
|
174
|
+
|
|
175
|
+
@padding -= @held.slice!(0, @padding).bytesize
|
|
176
|
+
return false if @padding.positive?
|
|
177
|
+
end
|
|
178
|
+
@entry = nil
|
|
179
|
+
true
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def field(header, start, length) = header[start, length].split("\0", 2).first.to_s.force_encoding("UTF-8")
|
|
183
|
+
|
|
184
|
+
def begin_entry(header)
|
|
185
|
+
size = if header.getbyte(124) & 0x80 != 0
|
|
186
|
+
header[125, 11].bytes.inject(0) { |sum, byte| (sum << 8) | byte }
|
|
187
|
+
else
|
|
188
|
+
field(header, 124, 12).strip.then { |text| text.empty? ? 0 : text.to_i(8) }
|
|
189
|
+
end
|
|
190
|
+
type = header[156]
|
|
191
|
+
prefix = field(header, 345, 155)
|
|
192
|
+
name = @long || (prefix.empty? ? field(header, 0, 100) : "#{prefix}/#{field(header, 0, 100)}")
|
|
193
|
+
link = @long_link || field(header, 157, 100)
|
|
194
|
+
@long = nil
|
|
195
|
+
@long_link = nil
|
|
196
|
+
@long_link = nil
|
|
197
|
+
mode = field(header, 100, 8).strip.then { |text| text.empty? ? 0o644 : text.to_i(8) }
|
|
198
|
+
@left = size
|
|
199
|
+
@padding = -size % 512
|
|
200
|
+
@entry = {}
|
|
201
|
+
if %w[L K x].include?(type)
|
|
202
|
+
@entry = { long: "".b, type: type }
|
|
203
|
+
else
|
|
204
|
+
name = name.delete_prefix("./")
|
|
205
|
+
unless name.empty? || name == "." || name == "./"
|
|
206
|
+
destination = File.expand_path(name, @root)
|
|
207
|
+
raise IOError, "Refusing an archive entry outside the target: #{name}" unless destination.start_with?("#{@root}/")
|
|
208
|
+
|
|
209
|
+
case type
|
|
210
|
+
when "5" then FileUtils.mkdir_p(destination)
|
|
211
|
+
when "1" then hard_link(destination, link, name)
|
|
212
|
+
when "0", "\0", "7"
|
|
213
|
+
FileUtils.mkdir_p(File.dirname(destination))
|
|
214
|
+
# A name already unpacked may share its file with a hard link;
|
|
215
|
+
# writing through it would change the link's copy too.
|
|
216
|
+
File.unlink(destination) if File.exist?(destination) || File.symlink?(destination)
|
|
217
|
+
@entry = { file: File.open(destination, "wb"), path: destination, mode: mode }
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end_body if @left.zero?
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def end_body
|
|
225
|
+
if @entry[:file]
|
|
226
|
+
@entry[:file].close
|
|
227
|
+
File.chmod(@entry[:mode] & 0o777, @entry[:path])
|
|
228
|
+
elsif @entry[:long]
|
|
229
|
+
body = @entry[:long]
|
|
230
|
+
case @entry[:type]
|
|
231
|
+
when "L" then @long = body.split("\0", 2).first.force_encoding("UTF-8")
|
|
232
|
+
when "K" then @long_link = body.split("\0", 2).first.force_encoding("UTF-8")
|
|
233
|
+
else @long = pax_path(body)
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
@entry = {}
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# A second name for a file the archive already carried: tar writes the
|
|
240
|
+
# first name as a file and every other as a hard link to it. The link
|
|
241
|
+
# names a plain file unpacked earlier in this archive, or the unpack
|
|
242
|
+
# fails; it never reaches outside or ahead.
|
|
243
|
+
def hard_link(destination, link, name)
|
|
244
|
+
source = File.expand_path(link.delete_prefix("./"), @root)
|
|
245
|
+
unless source.start_with?("#{@root}/") && !link.start_with?("/")
|
|
246
|
+
raise IOError, "Refusing an archive hard link that leads outside the target: #{name} -> #{link}"
|
|
247
|
+
end
|
|
248
|
+
unless File.file?(source) && !File.symlink?(source)
|
|
249
|
+
raise IOError, "Refusing an archive hard link to a file it does not carry: #{name} -> #{link}"
|
|
250
|
+
end
|
|
251
|
+
return if source == destination
|
|
252
|
+
|
|
253
|
+
FileUtils.mkdir_p(File.dirname(destination))
|
|
254
|
+
File.unlink(destination) if File.exist?(destination) || File.symlink?(destination)
|
|
255
|
+
begin
|
|
256
|
+
File.link(source, destination)
|
|
257
|
+
rescue SystemCallError
|
|
258
|
+
FileUtils.cp(source, destination, preserve: true)
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# A pax header's path record, if it has one.
|
|
263
|
+
def pax_path(body)
|
|
264
|
+
until body.empty?
|
|
265
|
+
length = body[/\A\d+/].to_i
|
|
266
|
+
key, value = body[0, length].split(" ", 2).last.chomp.split("=", 2)
|
|
267
|
+
return value.force_encoding("UTF-8") if key == "path"
|
|
268
|
+
|
|
269
|
+
body = body[length..]
|
|
270
|
+
end
|
|
271
|
+
nil
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def merge(from, to, root)
|
|
275
|
+
Dir.children(from).each do |name|
|
|
276
|
+
source = File.join(from, name)
|
|
277
|
+
destination = File.join(to, name)
|
|
278
|
+
through = destination.delete_prefix("#{root}/").split("/").each_with_object([root]) { |part, way| way << File.join(way.last, part) }
|
|
279
|
+
if through.drop(1).any? { |path| File.symlink?(path) }
|
|
280
|
+
raise IOError, "Refusing an archive entry that passes through a link: #{destination.delete_prefix("#{root}/")}"
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
if File.directory?(source) && File.directory?(destination)
|
|
284
|
+
merge(source, destination, root)
|
|
285
|
+
else
|
|
286
|
+
File.rename(source, destination)
|
|
287
|
+
end
|
|
103
288
|
end
|
|
104
289
|
end
|
|
105
290
|
end
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "digest"
|
|
3
4
|
require "json"
|
|
4
5
|
require "net/http"
|
|
5
6
|
require "securerandom"
|
|
@@ -10,7 +11,7 @@ module WithRuntime
|
|
|
10
11
|
# product: the same rules as the JavaScript, Python, Go and Java SDKs. Writes
|
|
11
12
|
# carry an idempotency key, made per call and kept across the client's own
|
|
12
13
|
# retries; transport failures, 429, 502, 503 and 504 are retried with backoff
|
|
13
|
-
# and jitter; a create waits out a full
|
|
14
|
+
# and jitter; a create waits out full slots without credit, a full quota or a full region.
|
|
14
15
|
class Transport
|
|
15
16
|
class OpeningCancelled < IOError; end
|
|
16
17
|
class Cancellation
|
|
@@ -41,6 +42,21 @@ module WithRuntime
|
|
|
41
42
|
action&.call
|
|
42
43
|
end
|
|
43
44
|
end
|
|
45
|
+
# How long one attempt waits for its answer to start. The API starts every
|
|
46
|
+
# answer within 120 s: Caddy answers 503 itself past that, and a longer call
|
|
47
|
+
# gets its 200 at 90 s and then its JSON (runtime-late-answer). An attempt
|
|
48
|
+
# with no answer by then is talking to a server that is gone, such as a
|
|
49
|
+
# controller that died with the call in flight while its address moved to
|
|
50
|
+
# the new leader: a client that only waits sends nothing the new leader
|
|
51
|
+
# could refuse (the leader drill on vin-5, 4 October 2026). It is sent
|
|
52
|
+
# again under the same key.
|
|
53
|
+
ANSWER_START = 125.0
|
|
54
|
+
class << self
|
|
55
|
+
attr_writer :answer_start
|
|
56
|
+
|
|
57
|
+
def answer_start = @answer_start || ANSWER_START
|
|
58
|
+
end
|
|
59
|
+
|
|
44
60
|
NETWORK_ERRORS = [IOError, SystemCallError, SocketError, Timeout::Error, OpenSSL::SSL::SSLError,
|
|
45
61
|
Net::HTTPBadResponse, Net::ProtocolError, EOFError].freeze
|
|
46
62
|
|
|
@@ -68,8 +84,8 @@ module WithRuntime
|
|
|
68
84
|
|
|
69
85
|
# The value of a JSON call: a Hash, an Array or nil.
|
|
70
86
|
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, **
|
|
87
|
+
with_connection(options, method) do |http, admitted|
|
|
88
|
+
response = send_call(http, method, path, query: query, body: body, **admitted)
|
|
73
89
|
text = response.body.to_s
|
|
74
90
|
text.strip.empty? ? nil : JSON.parse(text)
|
|
75
91
|
rescue JSON::ParserError => e
|
|
@@ -77,14 +93,79 @@ module WithRuntime
|
|
|
77
93
|
end
|
|
78
94
|
end
|
|
79
95
|
|
|
96
|
+
# A GET's body to the block a chunk at a time as it arrives. A connection
|
|
97
|
+
# lost part way is raised, never retried, so no chunk is given twice.
|
|
98
|
+
def chunks(path, query: nil, accept: "application/octet-stream", &block)
|
|
99
|
+
with_connection({}, "GET") do |http, admitted|
|
|
100
|
+
send_call(http, "GET", path, query: query, accept: accept, no_retry: true, **admitted, &block)
|
|
101
|
+
end
|
|
102
|
+
nil
|
|
103
|
+
end
|
|
104
|
+
|
|
80
105
|
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, **
|
|
106
|
+
with_connection(options, method) do |http, admitted|
|
|
107
|
+
send_call(http, method, path, query: query, body: body, **admitted).body.to_s.b
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# A file's bytes, checked against the length (x-content-length) and
|
|
112
|
+
# SHA-256 (x-content-sha256) the API sent with them: a stream that broke
|
|
113
|
+
# can arrive as one that ended, short. A short or different body is read
|
|
114
|
+
# again twice, then download_incomplete.
|
|
115
|
+
def file_bytes(path, query: nil, accept: "application/octet-stream", **options)
|
|
116
|
+
attempt = 0
|
|
117
|
+
loop do
|
|
118
|
+
response = with_connection(options, "GET") do |http, admitted|
|
|
119
|
+
send_call(http, "GET", path, query: query, accept: accept, **admitted)
|
|
120
|
+
end
|
|
121
|
+
body = response.body.to_s.b
|
|
122
|
+
problem = Transport.check_body(response, body)
|
|
123
|
+
return body unless problem
|
|
124
|
+
raise problem if attempt >= 2
|
|
125
|
+
|
|
126
|
+
sleep(backoff(attempt))
|
|
127
|
+
attempt += 1
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Why a whole file body cannot be trusted, or nil when it can.
|
|
132
|
+
def self.check_body(response, body)
|
|
133
|
+
expected = response["x-content-length"]
|
|
134
|
+
if expected&.match?(/\A\d+\z/) && body.bytesize != expected.to_i
|
|
135
|
+
return Error.new("The download ended at #{body.bytesize} of #{expected} bytes.",
|
|
136
|
+
code: "download_incomplete",
|
|
137
|
+
hint: "Read the file again; nothing was written for this read.",
|
|
138
|
+
details: { "received" => body.bytesize, "expected" => expected.to_i })
|
|
139
|
+
end
|
|
140
|
+
sha256 = response["x-content-sha256"]
|
|
141
|
+
return nil if sha256.nil? || sha256.empty? || Digest::SHA256.hexdigest(body).casecmp?(sha256)
|
|
142
|
+
|
|
143
|
+
Error.new("The file's bytes did not match the SHA-256 the API sent with them.",
|
|
144
|
+
code: "download_incomplete", hint: "Read the file again.")
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# The status a late answer's body failed with, or 200 when it is an
|
|
148
|
+
# answer. An API error is a body of `error` alone, with a code; an answer
|
|
149
|
+
# may carry an `error` of its own (a code cell's exception) beside other
|
|
150
|
+
# fields. A body that is not JSON was cut or garbled: 502.
|
|
151
|
+
def self.late_failure(text)
|
|
152
|
+
body = begin
|
|
153
|
+
JSON.parse(text)
|
|
154
|
+
rescue JSON::ParserError
|
|
155
|
+
return 502
|
|
83
156
|
end
|
|
157
|
+
return 200 unless body.is_a?(Hash) && body.size == 1
|
|
158
|
+
error = body["error"]
|
|
159
|
+
return 200 unless error.is_a?(Hash) && error["code"].is_a?(String)
|
|
160
|
+
|
|
161
|
+
status = error["status"]
|
|
162
|
+
status.is_a?(Numeric) && status >= 400 ? status.to_i : 500
|
|
84
163
|
end
|
|
85
164
|
|
|
86
165
|
# Newline-delimited JSON events as they arrive, one Hash per event, on a
|
|
87
|
-
# connection of their own.
|
|
166
|
+
# connection of their own. A connection lost after the first event is
|
|
167
|
+
# raised, never retried, so no event is given twice; the caller resumes
|
|
168
|
+
# from its cursor.
|
|
88
169
|
def events(method, path, query: nil, body: nil, cancel: nil, **options, &block)
|
|
89
170
|
return enum_for(:events, method, path, query: query, body: body, cancel: cancel, **options) unless block
|
|
90
171
|
|
|
@@ -205,7 +286,12 @@ module WithRuntime
|
|
|
205
286
|
end
|
|
206
287
|
end
|
|
207
288
|
|
|
208
|
-
|
|
289
|
+
# A connection from the pool, waited for within the call's own deadline;
|
|
290
|
+
# the wait is taken off it, so a queued call expires as an unqueued one
|
|
291
|
+
# would, without sending anything.
|
|
292
|
+
def with_connection(options, method)
|
|
293
|
+
limit = options[:timeout] || @timeout
|
|
294
|
+
deadline = now + limit
|
|
209
295
|
http = @lock.synchronize do
|
|
210
296
|
loop do
|
|
211
297
|
break @idle.pop unless @idle.empty?
|
|
@@ -213,12 +299,18 @@ module WithRuntime
|
|
|
213
299
|
@open += 1
|
|
214
300
|
break connect
|
|
215
301
|
end
|
|
216
|
-
|
|
302
|
+
left = deadline - now
|
|
303
|
+
raise connection_error(true, method != "GET", options[:key]) if left <= 0
|
|
304
|
+
|
|
305
|
+
@freed.wait(@lock, left)
|
|
217
306
|
end
|
|
218
307
|
end
|
|
219
308
|
healthy = false
|
|
220
309
|
begin
|
|
221
|
-
|
|
310
|
+
left = deadline - now
|
|
311
|
+
raise connection_error(true, method != "GET", options[:key]) if left <= 0
|
|
312
|
+
|
|
313
|
+
result = yield http, options.merge(timeout: left)
|
|
222
314
|
healthy = true
|
|
223
315
|
result
|
|
224
316
|
ensure
|
|
@@ -234,6 +326,35 @@ module WithRuntime
|
|
|
234
326
|
end
|
|
235
327
|
end
|
|
236
328
|
|
|
329
|
+
def deadline_watchdog(http, seconds)
|
|
330
|
+
Thread.new do
|
|
331
|
+
Thread.current.report_on_exception = false
|
|
332
|
+
sleep(seconds)
|
|
333
|
+
begin
|
|
334
|
+
http.instance_variable_get(:@socket)&.io&.close
|
|
335
|
+
rescue IOError, SystemCallError
|
|
336
|
+
nil
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# A connection that never opened: no byte of the request left this machine.
|
|
342
|
+
NEVER_OPENED = [Errno::ECONNREFUSED, Errno::ENOENT, Errno::EHOSTUNREACH, Errno::ENETUNREACH,
|
|
343
|
+
Errno::EADDRNOTAVAIL].freeze
|
|
344
|
+
|
|
345
|
+
# Whether a failure, or one of its causes, is a connection that never
|
|
346
|
+
# opened. A timeout or a reset never counts.
|
|
347
|
+
def self.never_sent?(error)
|
|
348
|
+
at = error
|
|
349
|
+
5.times do
|
|
350
|
+
return false if at.nil?
|
|
351
|
+
return true if NEVER_OPENED.any? { |kind| at.is_a?(kind) }
|
|
352
|
+
|
|
353
|
+
at = at.cause
|
|
354
|
+
end
|
|
355
|
+
false
|
|
356
|
+
end
|
|
357
|
+
|
|
237
358
|
def backoff(attempt) = [8.0, 0.25 * (2**[attempt, 10].min)].min * (0.5 + rand)
|
|
238
359
|
|
|
239
360
|
# 0.5 s, 1 s, 2 s, 4 s, then every 8 s, jittered, so a queue of CI jobs spreads out.
|
|
@@ -262,6 +383,8 @@ module WithRuntime
|
|
|
262
383
|
room_attempt = 0
|
|
263
384
|
payload = raw || (body.nil? ? nil : JSON.generate(body))
|
|
264
385
|
attempt = 0
|
|
386
|
+
# The last try never left, and was sent again at once.
|
|
387
|
+
resent_at_once = false
|
|
265
388
|
loop do
|
|
266
389
|
raise connection_error(true, write, key) if cancel&.cancelled?
|
|
267
390
|
left = deadline - now
|
|
@@ -278,9 +401,22 @@ module WithRuntime
|
|
|
278
401
|
request["Content-Type"] = raw ? "application/octet-stream" : "application/json"
|
|
279
402
|
request.body = payload
|
|
280
403
|
end
|
|
281
|
-
|
|
404
|
+
# Until its answer starts, a read waits at most answer_start; then the
|
|
405
|
+
# call's whole deadline again.
|
|
406
|
+
bounded = Transport.answer_start < left
|
|
407
|
+
answered = false
|
|
408
|
+
http.read_timeout = bounded ? Transport.answer_start : left
|
|
282
409
|
http.write_timeout = left if http.respond_to?(:write_timeout=)
|
|
410
|
+
http.open_timeout = [left, 30].min
|
|
411
|
+
# Per-read limits do not bound a body that keeps trickling in: at the
|
|
412
|
+
# deadline the socket is closed, and the read fails as a timeout.
|
|
413
|
+
watchdog = deadline_watchdog(http, left)
|
|
283
414
|
response = nil
|
|
415
|
+
# A stream that gave its consumer anything is never sent again: that
|
|
416
|
+
# would give the same events twice. The consumer's own errors are its
|
|
417
|
+
# own, never a reason to retry.
|
|
418
|
+
streamed = false
|
|
419
|
+
consumer_error = nil
|
|
284
420
|
begin
|
|
285
421
|
open_connection(http, cancel)
|
|
286
422
|
cancel&.register do
|
|
@@ -296,11 +432,27 @@ module WithRuntime
|
|
|
296
432
|
raise connection_error(true, write, key) if cancel&.cancelled?
|
|
297
433
|
if stream
|
|
298
434
|
http.request(request) do |answer|
|
|
435
|
+
answered = true
|
|
436
|
+
http.read_timeout = [deadline - now, 0.001].max
|
|
299
437
|
response = answer
|
|
300
|
-
|
|
438
|
+
# A late answer is read whole first: it may be a failure.
|
|
439
|
+
if answer.code.to_i < 300 && answer["runtime-late-answer"] != "true"
|
|
440
|
+
answer.read_body do |chunk|
|
|
441
|
+
streamed = true
|
|
442
|
+
begin
|
|
443
|
+
stream.call(chunk)
|
|
444
|
+
rescue Exception => e
|
|
445
|
+
consumer_error = e
|
|
446
|
+
raise
|
|
447
|
+
end
|
|
448
|
+
end
|
|
449
|
+
end
|
|
301
450
|
end
|
|
302
451
|
else
|
|
303
|
-
response = http.request(request)
|
|
452
|
+
response = http.request(request) do
|
|
453
|
+
answered = true
|
|
454
|
+
http.read_timeout = [deadline - now, 0.001].max
|
|
455
|
+
end
|
|
304
456
|
end
|
|
305
457
|
status = response.code.to_i
|
|
306
458
|
if status >= 300 && status < 400
|
|
@@ -308,14 +460,35 @@ module WithRuntime
|
|
|
308
460
|
end
|
|
309
461
|
rescue *NETWORK_ERRORS => e
|
|
310
462
|
http.finish if http.started?
|
|
311
|
-
|
|
312
|
-
raise connection_error(late, write, key, e) if late || no_retry || attempt >= max_retries
|
|
463
|
+
raise if e.equal?(consumer_error)
|
|
313
464
|
|
|
314
|
-
|
|
465
|
+
unanswered = bounded && !answered && e.is_a?(Net::ReadTimeout) && now < deadline
|
|
466
|
+
late = (e.is_a?(Timeout::Error) && !unanswered) || now >= deadline || cancel&.cancelled?
|
|
467
|
+
raise connection_error(late, write, key, e) if late || no_retry || streamed || attempt >= max_retries
|
|
468
|
+
|
|
469
|
+
# A connection that never opened sent no byte of the request, so the
|
|
470
|
+
# next try, which may reach another server, goes at once; once in a
|
|
471
|
+
# row, so an API that is down is not hammered. A reset or a timeout
|
|
472
|
+
# may have reached a server and keeps the backoff and its key's rules.
|
|
473
|
+
at_once = Transport.never_sent?(e) && !resent_at_once
|
|
474
|
+
resent_at_once = at_once
|
|
475
|
+
pause(at_once ? rand * 0.05 : backoff(attempt), deadline, write, key, nil, cancel)
|
|
315
476
|
attempt += 1
|
|
316
477
|
next
|
|
478
|
+
ensure
|
|
479
|
+
watchdog.kill
|
|
480
|
+
end
|
|
481
|
+
# An answer that took longer than the API holds its headers (90 s)
|
|
482
|
+
# comes as a 200 sent early, and then its JSON: the answer, or
|
|
483
|
+
# {"error": ...} when the work failed after the status went
|
|
484
|
+
# (runtime-late-answer). A failure is raised as the error it is,
|
|
485
|
+
# never returned as a success.
|
|
486
|
+
late = status < 300 && response["runtime-late-answer"] == "true"
|
|
487
|
+
status = Transport.late_failure(response.body.to_s) if late
|
|
488
|
+
if status < 300
|
|
489
|
+
stream&.call(response.body.to_s) if late
|
|
490
|
+
return response
|
|
317
491
|
end
|
|
318
|
-
return response if status < 300
|
|
319
492
|
|
|
320
493
|
text = response.body.to_s
|
|
321
494
|
parsed = begin
|
data/lib/withruntime/version.rb
CHANGED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module WithRuntime
|
|
8
|
+
# An account opened with a wallet alone: no key, no sign-in, no browser.
|
|
9
|
+
#
|
|
10
|
+
# opened = WithRuntime::Wallet.open_account(20, accept_terms: true)
|
|
11
|
+
# # send exactly opened["amount"] to one of opened["networks"], then, once paid:
|
|
12
|
+
# key = WithRuntime::Wallet.claim_account(opened["claimCode"])["apiKey"]
|
|
13
|
+
#
|
|
14
|
+
# Opening one means agreeing to https://withruntime.com/legal/terms, so ask the
|
|
15
|
+
# person you work for first. The claim code is shown once and is the only way
|
|
16
|
+
# to the account's key: keep it secret. These two calls go to the website;
|
|
17
|
+
# every call after the key goes to the API as usual.
|
|
18
|
+
module Wallet
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# Opens an account and a stablecoin top-up of +usd+ dollars ($10 to
|
|
22
|
+
# $500: it is a new account's first week). Returns "claimCode", "amount" (the exact token amount, to six
|
|
23
|
+
# decimals), "networks" (an "address" on each and its "tokens") and "payBy".
|
|
24
|
+
def open_account(usd, accept_terms:, auth_url: nil, timeout: 30)
|
|
25
|
+
post(auth_url, "/api/wallet/topups", { "usd" => usd, "acceptTerms" => accept_terms }, timeout)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Once the top-up is paid, {"status" => "paid", "apiKey" => ...}, once.
|
|
29
|
+
# Before that, the top-up's "status"; call again.
|
|
30
|
+
def claim_account(claim_code, auth_url: nil, timeout: 30)
|
|
31
|
+
post(auth_url, "/api/wallet/claim", { "claimCode" => claim_code }, timeout)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def origin(auth_url)
|
|
35
|
+
value = auth_url.to_s.empty? ? ENV["RUNTIME_AUTH_URL"].to_s : auth_url
|
|
36
|
+
(value.empty? ? "https://withruntime.com" : value).chomp("/")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# One POST, never redirected, with no Authorization header.
|
|
40
|
+
def post(auth_url, path, body, timeout)
|
|
41
|
+
base = origin(auth_url)
|
|
42
|
+
uri = URI("#{base}#{path}")
|
|
43
|
+
http = Net::HTTP.new(uri.host, uri.port, nil)
|
|
44
|
+
http.use_ssl = uri.scheme == "https"
|
|
45
|
+
http.open_timeout = http.read_timeout = http.write_timeout = timeout
|
|
46
|
+
request = Net::HTTP::Post.new(uri.request_uri, "Content-Type" => "application/json")
|
|
47
|
+
request.body = JSON.generate(body)
|
|
48
|
+
answer = begin
|
|
49
|
+
http.start { |connection| connection.request(request) }
|
|
50
|
+
rescue *Transport::NETWORK_ERRORS
|
|
51
|
+
raise Error.new("No answer from #{base}.", code: "network", hint: "Retry in a minute.")
|
|
52
|
+
end
|
|
53
|
+
said = begin
|
|
54
|
+
JSON.parse(answer.body.to_s)
|
|
55
|
+
rescue JSON::ParserError
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
said = {} unless said.is_a?(Hash)
|
|
59
|
+
status = answer.code.to_i
|
|
60
|
+
return said if status.between?(200, 299)
|
|
61
|
+
|
|
62
|
+
raise Error.new(said["error"].is_a?(String) ? said["error"] : "The request failed (HTTP #{status}).",
|
|
63
|
+
code: said["code"].is_a?(String) ? said["code"] : (status == 429 ? "rate_limited" : "request_failed"),
|
|
64
|
+
status: status)
|
|
65
|
+
end
|
|
66
|
+
private_class_method :origin, :post
|
|
67
|
+
end
|
|
68
|
+
end
|
data/lib/withruntime.rb
CHANGED
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
# require "withruntime"
|
|
7
7
|
#
|
|
8
8
|
# runtime = WithRuntime::Client.new # RUNTIME_API_KEY, or this machine's saved connection
|
|
9
|
-
# sbx = runtime.sandboxes.create
|
|
10
|
-
#
|
|
9
|
+
# sbx = runtime.sandboxes.create
|
|
10
|
+
# sbx.files.write("/workspace/invoice.py", "print(sum([125, 250, 375]))\n")
|
|
11
|
+
# puts sbx.exec("python3 /workspace/invoice.py", check: true).stdout
|
|
11
12
|
# sbx.stop
|
|
12
13
|
#
|
|
13
14
|
# The guide is at https://withruntime.com/docs/ruby.
|
|
@@ -16,10 +17,12 @@ require "openssl"
|
|
|
16
17
|
|
|
17
18
|
require_relative "withruntime/version"
|
|
18
19
|
require_relative "withruntime/errors"
|
|
20
|
+
require_relative "withruntime/api_defaults"
|
|
19
21
|
require_relative "withruntime/record"
|
|
20
22
|
require_relative "withruntime/fields"
|
|
21
23
|
require_relative "withruntime/transport"
|
|
22
24
|
require_relative "withruntime/credentials"
|
|
25
|
+
require_relative "withruntime/wallet"
|
|
23
26
|
require_relative "withruntime/tar"
|
|
24
27
|
require_relative "withruntime/sandbox"
|
|
25
28
|
require_relative "withruntime/products"
|