bynn-decisionnode 0.4.1

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.
@@ -0,0 +1,249 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "openssl"
5
+ require "uri"
6
+
7
+ class DecisionNode
8
+ # A response as the client sees it: the status, the headers with lower-case
9
+ # names, and the body text. A transport that can follow redirects sets `url`
10
+ # to the address the response came from; the client refuses a response from
11
+ # any other address than the one it asked for.
12
+ HTTPResponse = Struct.new(:status, :headers, :body, :url)
13
+
14
+ # The default transport: Net::HTTP with a pool of kept-alive connections per
15
+ # host, safe to share between threads, so repeated calls skip the TCP and
16
+ # TLS handshakes. It never follows a redirect.
17
+ #
18
+ # DecisionNode::NetHTTPTransport.new(
19
+ # proxy: "http://proxy.example.com:3128",
20
+ # net_http: { ca_file: "/etc/ssl/certs/company-ca.pem" }
21
+ # )
22
+ #
23
+ # max_idle:: idle connections kept per host (default 16).
24
+ # keep_alive_timeout:: seconds an idle connection is reused for (default 15).
25
+ # proxy:: a proxy URL, else the proxy environment variables Net::HTTP reads.
26
+ # net_http:: Net::HTTP settings applied to every connection, by setter name
27
+ # (ca_file, ca_path, cert_store, cert, key, ...). Settings that
28
+ # could turn TLS or its certificate checks off are refused with
29
+ # ArgumentError: use_ssl (the transport sets it from the URL),
30
+ # verify_mode other than VERIFY_PEER, verify_hostname false and
31
+ # verify_callback. For an https URL TLS with peer and hostname
32
+ # checks is set again after the caller's settings.
33
+ #
34
+ # Any object with the same `call` method can be passed to the client as
35
+ # `http_client:`. It returns an HTTPResponse and raises
36
+ # DecisionNode::TimeoutError or DecisionNode::ConnectionError when no response
37
+ # arrives. Methods are GET, POST, PUT (an image upload to the storage URL the
38
+ # API returned, with exactly the headers given) and DELETE. It must never follow a redirect and must say so by answering false
39
+ # to `follow_redirects?`; the client refuses any other transport. A response
40
+ # whose `url` differs from the requested one is an error.
41
+ class NetHTTPTransport
42
+ TIMEOUT_ERRORS = [Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout, ::Timeout::Error].freeze
43
+ CONNECTION_ERRORS = [
44
+ SystemCallError, IOError, SocketError, EOFError, OpenSSL::SSL::SSLError,
45
+ Net::HTTPBadResponse, Net::ProtocolError
46
+ ].freeze
47
+
48
+ def initialize(max_idle: 16, keep_alive_timeout: 15, proxy: nil, net_http: {})
49
+ @max_idle = max_idle
50
+ @keep_alive_timeout = keep_alive_timeout
51
+ @proxy = proxy.nil? ? nil : URI.parse(proxy.to_s)
52
+ @net_http = net_http.to_h { |k, v| [:"#{k.to_s.delete_suffix('=')}=", v] }
53
+ @net_http.each do |setter, value|
54
+ raise ArgumentError, "net_http: Net::HTTP has no setting #{setter.to_s.chomp('=')}" unless Net::HTTP.method_defined?(setter)
55
+
56
+ check_tls_setting(setter, value)
57
+ end
58
+ @idle = Hash.new { |h, k| h[k] = [] }
59
+ @lock = Mutex.new
60
+ end
61
+
62
+ def call(method, url, headers, body, timeout)
63
+ uri = URI.parse(url)
64
+ http = checkout(uri, timeout)
65
+ keep = false
66
+ begin
67
+ request = build_request(method, uri, headers, body)
68
+ response = http.request(request)
69
+ result = HTTPResponse.new(response.code.to_i, lower_headers(response), response.body.to_s)
70
+ keep = !response["connection"].to_s.casecmp?("close")
71
+ result
72
+ rescue *TIMEOUT_ERRORS => e
73
+ raise DecisionNode::TimeoutError, "no response within #{timeout} s (#{e.class})"
74
+ rescue *CONNECTION_ERRORS => e
75
+ raise DecisionNode::ConnectionError, "connection error: #{e.message} (#{e.class})"
76
+ ensure
77
+ keep ? checkin(uri, http) : discard(http)
78
+ end
79
+ end
80
+
81
+ # Like #call, but a 2xx body is handed to the block in chunks as it
82
+ # arrives, each read bounded by `timeout`, and the returned response has
83
+ # an empty body. Any other status is read whole. Used for batch results.
84
+ def stream(method, url, headers, timeout, &on_chunk)
85
+ uri = URI.parse(url)
86
+ http = checkout(uri, timeout)
87
+ keep = false
88
+ begin
89
+ result = nil
90
+ http.request(build_request(method, uri, headers, nil)) do |response|
91
+ status = response.code.to_i
92
+ if (200..299).cover?(status)
93
+ response.read_body { |chunk| on_chunk.call(chunk) }
94
+ result = HTTPResponse.new(status, lower_headers(response), "")
95
+ else
96
+ result = HTTPResponse.new(status, lower_headers(response), response.read_body.to_s)
97
+ end
98
+ keep = !response["connection"].to_s.casecmp?("close")
99
+ end
100
+ result
101
+ rescue *TIMEOUT_ERRORS => e
102
+ raise DecisionNode::TimeoutError, "no response within #{timeout} s (#{e.class})"
103
+ rescue *CONNECTION_ERRORS => e
104
+ raise DecisionNode::ConnectionError, "connection error: #{e.message} (#{e.class})"
105
+ ensure
106
+ keep ? checkin(uri, http) : discard(http)
107
+ end
108
+ end
109
+
110
+ # What a session's socket takes from this transport: the proxy (:env when
111
+ # none was given, so the environment's applies as it does here) and the
112
+ # `net_http:` settings that apply to TLS.
113
+ def session_options
114
+ tls = @net_http.each_with_object({}) { |(setter, value), h| h[setter.to_s.chomp("=")] = value }
115
+ { proxy: @proxy || :env, tls: tls.select { |name, _| DecisionNode::SessionStream::TLS_SETTINGS.include?(name) } }
116
+ end
117
+
118
+ # Net::HTTP never follows a redirect on its own.
119
+ def follow_redirects?
120
+ false
121
+ end
122
+
123
+ # Closes every idle connection.
124
+ def close
125
+ conns = @lock.synchronize do
126
+ all = @idle.values.flatten
127
+ @idle.clear
128
+ all
129
+ end
130
+ conns.each { |c| discard(c) }
131
+ end
132
+
133
+ private
134
+
135
+ def key_for(uri)
136
+ [uri.scheme, uri.host, uri.port]
137
+ end
138
+
139
+ def checkout(uri, timeout)
140
+ http = @lock.synchronize { @idle[key_for(uri)].pop }
141
+ http ||= new_http(uri)
142
+ http.open_timeout = timeout
143
+ http.read_timeout = timeout
144
+ http.write_timeout = timeout
145
+ http.ssl_timeout = timeout if http.use_ssl?
146
+ begin
147
+ http.start unless http.started?
148
+ rescue *TIMEOUT_ERRORS => e
149
+ raise DecisionNode::TimeoutError, "no connection within #{timeout} s (#{e.class})"
150
+ rescue *CONNECTION_ERRORS => e
151
+ raise DecisionNode::ConnectionError, "cannot connect to #{uri.host}: #{e.message} (#{e.class})"
152
+ end
153
+ http
154
+ end
155
+
156
+ def new_http(uri)
157
+ http =
158
+ if @proxy
159
+ Net::HTTP.new(uri.host, uri.port, @proxy.host, @proxy.port, @proxy.user, @proxy.password)
160
+ else
161
+ Net::HTTP.new(uri.host, uri.port)
162
+ end
163
+ @net_http.each { |setter, value| http.public_send(setter, value) }
164
+ # Set after the caller's settings, so none of them can turn TLS off.
165
+ if uri.scheme == "https"
166
+ http.use_ssl = true
167
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
168
+ http.verify_hostname = true
169
+ http.verify_callback = nil
170
+ else
171
+ http.use_ssl = false
172
+ end
173
+ http.max_retries = 0 # retries are the client's, so every attempt is counted
174
+ http.keep_alive_timeout = @keep_alive_timeout
175
+ http
176
+ end
177
+
178
+ def check_tls_setting(setter, value)
179
+ name = setter.to_s.chomp("=")
180
+ case name
181
+ when "use_ssl"
182
+ raise ArgumentError, "net_http: use_ssl is set from the URL and cannot be given"
183
+ when "verify_mode"
184
+ return if value == OpenSSL::SSL::VERIFY_PEER
185
+
186
+ raise ArgumentError, "net_http: verify_mode must be OpenSSL::SSL::VERIFY_PEER; certificate checks cannot be turned off"
187
+ when "verify_hostname"
188
+ return if value == true
189
+
190
+ raise ArgumentError, "net_http: verify_hostname cannot be turned off"
191
+ when "verify_callback"
192
+ raise ArgumentError, "net_http: verify_callback cannot be given; it could accept any certificate"
193
+ end
194
+ end
195
+
196
+ def checkin(uri, http)
197
+ overflow = @lock.synchronize do
198
+ list = @idle[key_for(uri)]
199
+ if list.size < @max_idle
200
+ list.push(http)
201
+ nil
202
+ else
203
+ http
204
+ end
205
+ end
206
+ discard(overflow) if overflow
207
+ end
208
+
209
+ def discard(http)
210
+ http.finish if http&.started?
211
+ rescue StandardError
212
+ nil
213
+ end
214
+
215
+ def build_request(method, uri, headers, body)
216
+ path = uri.request_uri
217
+ request =
218
+ case method
219
+ when "GET" then Net::HTTP::Get.new(path)
220
+ when "POST" then Net::HTTP::Post.new(path)
221
+ when "PATCH" then Net::HTTP::Patch.new(path)
222
+ when "PUT" then bare(Net::HTTP::Put.new(path))
223
+ when "DELETE" then Net::HTTP::Delete.new(path)
224
+ else raise ArgumentError, "unsupported method #{method}"
225
+ end
226
+ headers.each { |name, value| request[name] = value }
227
+ if body
228
+ request.body = body
229
+ elsif request.request_body_permitted?
230
+ # A POST without a body (finalize, cancel) goes out with Content-Length 0
231
+ # and no Content-Type, rather than the form type Net::HTTP would add.
232
+ request.content_length = 0
233
+ request.define_singleton_method(:request_body_permitted?) { false }
234
+ end
235
+ request
236
+ end
237
+
238
+ # A storage PUT carries exactly the headers it is given: none of the
239
+ # defaults Net::HTTP adds on its own.
240
+ def bare(request)
241
+ %w[accept accept-encoding user-agent].each { |name| request.delete(name) }
242
+ request
243
+ end
244
+
245
+ def lower_headers(response)
246
+ response.each_header.each_with_object({}) { |(k, v), h| h[k.downcase] = v }
247
+ end
248
+ end
249
+ end
@@ -0,0 +1,422 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require "uri"
5
+
6
+ class DecisionNode
7
+ # The client's memory of the images it uploaded: by the SHA-256 of the bytes
8
+ # and the media type, the upload id and when it stops being usable. An entry
9
+ # is used until 5 minutes before the upload's `expires_at`; at most 256
10
+ # entries, the least recently used out first. Never on disk, never shared
11
+ # between clients.
12
+ class UploadCache
13
+ MAX_ENTRIES = 256
14
+ # Seconds before `expires_at` from which an entry is no longer used.
15
+ MARGIN = 300
16
+
17
+ Entry = Struct.new(:upload_id, :expires_at)
18
+
19
+ def initialize(max_entries: MAX_ENTRIES, clock: -> { Time.now.to_f })
20
+ @max = max_entries
21
+ @clock = clock
22
+ @entries = {}
23
+ @lock = Mutex.new
24
+ end
25
+
26
+ def empty?
27
+ @entries.empty?
28
+ end
29
+
30
+ def size
31
+ @entries.size
32
+ end
33
+
34
+ # The live upload id for these bytes, or nil.
35
+ def get(key)
36
+ @lock.synchronize do
37
+ entry = @entries.delete(key)
38
+ return nil if entry.nil? || @clock.call >= entry.expires_at - MARGIN
39
+
40
+ @entries[key] = entry # most recently used last
41
+ entry.upload_id
42
+ end
43
+ end
44
+
45
+ def put(key, upload_id, expires_at)
46
+ @lock.synchronize do
47
+ @entries.delete(key)
48
+ @entries[key] = Entry.new(upload_id, expires_at)
49
+ @entries.delete(@entries.first.first) while @entries.size > @max
50
+ end
51
+ end
52
+
53
+ # Forgets every entry naming one of these upload ids.
54
+ def drop(upload_ids)
55
+ @lock.synchronize { @entries.delete_if { |_, e| upload_ids.include?(e.upload_id) } }
56
+ end
57
+
58
+ def clear
59
+ @lock.synchronize { @entries.clear }
60
+ end
61
+ end
62
+
63
+ # Image preparation and uploads, mixed into the client.
64
+ module Uploads
65
+ # A `400` with one of these error types names an upload that can no longer
66
+ # be used: an upload the SDK placed is uploaded again and the request sent
67
+ # once more.
68
+ STALE_UPLOAD = %w[upload_expired upload_consumed upload_not_found upload_incomplete].freeze
69
+
70
+ # An upload id of the API's length (36 characters), naming an image that
71
+ # would be uploaded when a body is measured before anything is sent.
72
+ UPLOAD_ID_PLACEHOLDER = "upl_#{'0' * 32}".freeze
73
+ private_constant :UPLOAD_ID_PLACEHOLDER
74
+
75
+ # Uploads every image, whatever its size, and returns one upload id per
76
+ # image (from the cache when the same bytes were uploaded before). URL
77
+ # images and upload ids cannot be uploaded and raise ArgumentError.
78
+ #
79
+ # Sends one `POST /v1/uploads` for the images not in the cache, then one
80
+ # `PUT` per image, in parallel. A call takes 1 to 16 images; more raise
81
+ # the API's BadRequestError before anything is sent. An id names the
82
+ # upload for an hour, in any image slot: `Image.new("receipt", upload_id: id)`.
83
+ def upload(images)
84
+ raise ArgumentError, "images must be an Array of DecisionNode::Image" unless images.is_a?(Array)
85
+
86
+ images.each do |img|
87
+ next if img.is_a?(Image) && img.local?
88
+
89
+ raise ArgumentError, "upload takes images made from a path, bytes, base64 or an IO"
90
+ end
91
+ if images.size > Checks::MAX_IMAGES
92
+ raise BadRequestError.new("files: 1 to #{Checks::MAX_IMAGES} files, got #{images.size}.", error_type: "api_usage_error")
93
+ end
94
+
95
+ Checks.images!(images, limits: limits_for(nil))
96
+ ids = images.map { |img| @uploads.get(cache_key(img)) }
97
+ missing = images.each_index.reject { |i| ids[i] }
98
+ fresh = missing.empty? ? [] : upload_images(missing.map { |i| images[i] })
99
+ missing.each_with_index { |i, j| ids[i] = fresh[j] }
100
+ ids
101
+ end
102
+
103
+ # Uploads 1 to 16 documents for knowledge bases, whatever their type and
104
+ # size, and returns one upload id each, in order, for a later
105
+ # `Document.new(upload_id: id)`. A text document is uploaded as
106
+ # text/markdown, or text/plain for format "text". A document given by its
107
+ # upload id raises ArgumentError. Each file is checked before anything is
108
+ # sent, as the API checks it (at most 52,428,800 bytes, not empty, one of
109
+ # Document::CONTENT_TYPES), and nothing is cached: an upload is consumed by
110
+ # the item it makes. An upload not named within its hour expires.
111
+ def upload_documents(documents)
112
+ raise ArgumentError, "documents must be an Array of DecisionNode::Document" unless documents.is_a?(Array)
113
+
114
+ documents.each do |doc|
115
+ next if doc.is_a?(Document) && !doc.upload_id
116
+
117
+ raise ArgumentError, "upload_documents takes documents made from a text, a path, bytes or an IO"
118
+ end
119
+ unless documents.size.between?(1, Checks::MAX_IMAGES)
120
+ raise BadRequestError.new("files: 1 to #{Checks::MAX_IMAGES} files, got #{documents.size}.",
121
+ error_type: "api_usage_error")
122
+ end
123
+
124
+ upload_knowledge(documents.each_with_index.map { |doc, i| document_file(doc, i) })
125
+ end
126
+
127
+ private
128
+
129
+ # A document's bytes as a knowledge file: a text as its UTF-8 bytes,
130
+ # typed by its format.
131
+ def document_file(doc, index)
132
+ return doc.load(index) unless doc.text?
133
+
134
+ unless doc.format.nil? || Document::FORMATS.include?(doc.format)
135
+ raise Checks.invalid([Checks.entry("literal_error", %w[body format], "Input should be 'markdown' or 'text'",
136
+ doc.format)])
137
+ end
138
+ type = doc.format == "text" ? "text/plain" : "text/markdown"
139
+ Document.new(data: doc.text.encode(Encoding::UTF_8), content_type: type).load(index)
140
+ end
141
+
142
+ # One presign for the files with the knowledge purpose, then one PUT each
143
+ # (in parallel), each with the document timeout. Returns the upload ids.
144
+ def upload_knowledge(loaded)
145
+ files = loaded.map do |f|
146
+ file = { "purpose" => "knowledge", "content_type" => f.content_type, "byte_size" => f.data.bytesize,
147
+ "checksum" => [f.md5].pack("m0"), "sha256" => [f.sha256].pack("m0") }
148
+ file["filename"] = f.filename unless f.filename.nil?
149
+ file
150
+ end
151
+ targets, valid_until = presign(files)
152
+ if targets.size != loaded.size
153
+ raise ServerError.new("the upload answer holds #{targets.size} uploads for #{loaded.size} files")
154
+ end
155
+
156
+ put_all(loaded, files, targets, valid_until, timeout: @document_upload_timeout)
157
+ targets.map { |t| t["upload_id"] }
158
+ end
159
+
160
+ # The per-attempt timeout of a document write: 20 s with `wait` unless
161
+ # the caller set one; nil (the client's) otherwise.
162
+ def write_timeout(wait)
163
+ wait && !@timeout_given ? WAIT_TIMEOUT : nil
164
+ end
165
+
166
+ def cache_key(img)
167
+ [img.sha256, img.media_type]
168
+ end
169
+
170
+ # Forgets uploads the API called stale, so the next image_wires uploads
171
+ # those images again.
172
+ def drop_uploads(upload_ids)
173
+ @uploads.drop(upload_ids)
174
+ end
175
+
176
+ # The wire forms of a request's images and the upload ids the SDK placed in
177
+ # them. URLs and upload ids go as given; a cached upload by its id whatever
178
+ # its size; the other local images inline when all the local images total
179
+ # under the inline threshold, else uploaded. With project: true nothing is
180
+ # uploaded: an image that would be is named by UPLOAD_ID_PLACEHOLDER.
181
+ def image_wires(images, project: false)
182
+ wires = Array.new(images.size)
183
+ placed = []
184
+ pending = []
185
+ total = 0
186
+ images.each_with_index do |img, i|
187
+ unless img.is_a?(Image)
188
+ wires[i] = img
189
+ next
190
+ end
191
+ unless img.local?
192
+ wires[i] = img.to_h
193
+ next
194
+ end
195
+ total += img.bytesize
196
+ hit = @uploads.empty? ? nil : @uploads.get(cache_key(img))
197
+ if hit
198
+ wires[i] = img.upload_wire(hit)
199
+ placed << hit
200
+ else
201
+ pending << i
202
+ end
203
+ end
204
+ return [wires, placed] if pending.empty?
205
+
206
+ if total >= @inline_threshold
207
+ ids = project ? [UPLOAD_ID_PLACEHOLDER] * pending.size : upload_images(pending.map { |i| images[i] })
208
+ pending.each_with_index do |i, j|
209
+ wires[i] = images[i].upload_wire(ids[j])
210
+ placed << ids[j]
211
+ end
212
+ else
213
+ pending.each { |i| wires[i] = images[i].to_h }
214
+ end
215
+ [wires, placed]
216
+ end
217
+
218
+ # One presign for the distinct images, one PUT each (in parallel), then the
219
+ # cache. Returns one upload id per image, in order. Bytes another thread is
220
+ # uploading at the same moment are waited for, not uploaded twice.
221
+ def upload_images(images)
222
+ distinct = images.uniq { |img| cache_key(img) }
223
+ mine, theirs = claim(distinct)
224
+ by_key = {}
225
+ begin
226
+ transfer(mine).each { |key, id| by_key[key] = id } unless mine.empty?
227
+ ensure
228
+ release(mine, by_key)
229
+ end
230
+ theirs.each { |key, pending| by_key[key] = pending.value }
231
+ images.map { |img| by_key[cache_key(img)] }
232
+ end
233
+
234
+ # The upload of one set of bytes by another thread.
235
+ class Pending
236
+ def initialize
237
+ @lock = Mutex.new
238
+ @done = ConditionVariable.new
239
+ @state = nil
240
+ end
241
+
242
+ def resolve(id)
243
+ settle([:ok, id])
244
+ end
245
+
246
+ def reject
247
+ settle([:failed])
248
+ end
249
+
250
+ # The upload id, or the upload's failure raised.
251
+ def value
252
+ @lock.synchronize do
253
+ @done.wait(@lock) while @state.nil?
254
+ raise DecisionNodeError.new("the upload of the same image by another call failed", error_type: "upload_failed") if @state.first == :failed
255
+
256
+ @state.last
257
+ end
258
+ end
259
+
260
+ private
261
+
262
+ def settle(state)
263
+ @lock.synchronize do
264
+ @state = state
265
+ @done.broadcast
266
+ end
267
+ end
268
+ end
269
+ private_constant :Pending
270
+
271
+ # Splits the images into those this call uploads and those another call is
272
+ # uploading now.
273
+ def claim(images)
274
+ mine = []
275
+ theirs = {}
276
+ @inflight_lock.synchronize do
277
+ images.each do |img|
278
+ key = cache_key(img)
279
+ if @inflight.key?(key)
280
+ theirs[key] = @inflight[key]
281
+ else
282
+ @inflight[key] = Pending.new
283
+ mine << img
284
+ end
285
+ end
286
+ end
287
+ [mine, theirs]
288
+ end
289
+
290
+ def release(images, by_key)
291
+ pendings = @inflight_lock.synchronize { images.map { |img| @inflight.delete(cache_key(img)) } }
292
+ images.zip(pendings).each do |img, pending|
293
+ id = by_key[cache_key(img)]
294
+ id ? pending.resolve(id) : pending.reject
295
+ end
296
+ end
297
+
298
+ # The presign and the PUTs; returns cache key to upload id.
299
+ def transfer(images)
300
+ files = images.map do |img|
301
+ { "content_type" => img.media_type, "byte_size" => img.bytesize,
302
+ "checksum" => [img.md5].pack("m0"), "sha256" => [img.sha256].pack("m0") }
303
+ end
304
+ targets, valid_until = presign(files)
305
+ if targets.size != images.size
306
+ raise ServerError.new("the upload answer holds #{targets.size} uploads for #{images.size} files")
307
+ end
308
+
309
+ put_all(images, files, targets, valid_until)
310
+ images.each_with_index.to_h do |img, i|
311
+ target = targets[i]
312
+ @uploads.put(cache_key(img), target["upload_id"], expiry(target["expires_at"]))
313
+ [cache_key(img), target["upload_id"]]
314
+ end
315
+ end
316
+
317
+ def presign(files)
318
+ body, = request("POST", "/v1/uploads", { "files" => files })
319
+ uploads = body.is_a?(Hash) ? body["uploads"] : nil
320
+ unless uploads.is_a?(Array) && uploads.all? { |u| u.is_a?(Hash) && u["upload_id"].is_a?(String) && u["url"].is_a?(String) }
321
+ raise ServerError.new("the upload answer has no uploads list", body: body)
322
+ end
323
+
324
+ uploads.each { |u| storage_uri(u["url"]) } # every URL is checked before any byte is sent
325
+ ttl = body["expires_in"].is_a?(Numeric) ? body["expires_in"] : 300
326
+ [uploads, @clock.call + ttl] # the URLs live expires_in seconds, by the client's clock
327
+ end
328
+
329
+ # The PUTs, in parallel when there are several. The first failure is raised
330
+ # once every PUT has ended.
331
+ def put_all(images, files, targets, valid_until, timeout: @upload_timeout)
332
+ if images.size == 1
333
+ put_object(images[0], files[0], targets[0], valid_until, timeout: timeout)
334
+ return
335
+ end
336
+
337
+ errors = Array.new(images.size)
338
+ threads = images.each_index.map do |i|
339
+ Thread.new do
340
+ put_object(images[i], files[i], targets[i], valid_until, timeout: timeout)
341
+ rescue StandardError => e
342
+ errors[i] = e
343
+ end
344
+ end
345
+ threads.each(&:join)
346
+ first = errors.compact.first
347
+ raise first if first
348
+ end
349
+
350
+ # One PUT to the storage URL with exactly the returned headers and the
351
+ # bytes: no key, none of the client's headers, no redirect followed. Each
352
+ # attempt has the client's upload_timeout, apart from the API timeout.
353
+ # Retried on 5xx and connection errors as an API call is; a 412 on a retry
354
+ # means an earlier attempt stored the object; a 403 after the URL's time
355
+ # is up presigns again, once. On success `target` names the stored upload.
356
+ # A document's PUT has its own timeout.
357
+ def put_object(image, file, target, valid_until, represigned: false, timeout: @upload_timeout)
358
+ url = target["url"]
359
+ headers = (target["headers"] || {}).to_h { |k, v| [k.to_s, v.to_s] }
360
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
361
+ attempt = 0
362
+ loop do
363
+ response = begin
364
+ attempt_once("PUT", url, headers, image.data, timeout: timeout)
365
+ rescue TimeoutError, ConnectionError => e
366
+ log("PUT", "storage", e.class.name.split("::").last, nil, started, attempt)
367
+ raise if attempt >= @max_retries
368
+
369
+ @sleeper.call(backoff(attempt))
370
+ attempt += 1
371
+ next
372
+ end
373
+ log("PUT", "storage", response.status, nil, started, attempt)
374
+ status = response.status
375
+ return if (200..299).cover?(status) || (status == 412 && attempt.positive?)
376
+
377
+ if status >= 500 && attempt < @max_retries
378
+ @sleeper.call(retry_wait(response.headers) || backoff(attempt))
379
+ attempt += 1
380
+ next
381
+ end
382
+ if status == 403 && !represigned && @clock.call >= valid_until
383
+ fresh, fresh_until = presign([file])
384
+ put_object(image, file, fresh.first, fresh_until, represigned: true, timeout: timeout)
385
+ target.replace(fresh.first)
386
+ return
387
+ end
388
+ raise storage_error(response)
389
+ end
390
+ end
391
+
392
+ def storage_error(response)
393
+ code = response.body.to_s[%r{<Code>([^<]*)</Code>}, 1]
394
+ DecisionNodeError.new("the upload to storage failed with HTTP #{response.status}",
395
+ status: response.status, error_type: "upload_failed", reason: code,
396
+ headers: response.headers)
397
+ end
398
+
399
+ # A storage URL must be https; plain http only on a loopback host.
400
+ def storage_uri(url)
401
+ uri = URI.parse(url)
402
+ host = uri.host.to_s.delete_prefix("[").delete_suffix("]").downcase
403
+ return uri if uri.scheme&.downcase == "https" && !host.empty?
404
+ return uri if uri.scheme&.downcase == "http" && LOOPBACK_HOSTS.include?(host)
405
+
406
+ raise DecisionNodeError.new("the storage URL must use https, got #{uri.scheme}://#{uri.host}",
407
+ error_type: "upload_failed")
408
+ rescue URI::InvalidURIError
409
+ raise DecisionNodeError.new("the storage URL is not a valid URL", error_type: "upload_failed")
410
+ end
411
+
412
+ def expiry(text)
413
+ Time.iso8601(text.to_s).to_f
414
+ rescue ArgumentError
415
+ begin
416
+ Time.httpdate(text.to_s).to_f
417
+ rescue ArgumentError
418
+ Time.now.to_f + UploadCache::MARGIN + 60 # unknown: usable for a minute
419
+ end
420
+ end
421
+ end
422
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ class DecisionNode
4
+ # The version of this gem.
5
+ VERSION = "0.4.1"
6
+
7
+ # The version of the DecisionNode SDK surface this gem implements.
8
+ SPEC_VERSION = "1.4"
9
+ end