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,1100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+ require "timeout"
6
+ require "uri"
7
+
8
+ class DecisionNode
9
+ include Uploads
10
+
11
+ DEFAULT_BASE_URL = "https://api.decisionnode.com"
12
+ DEFAULT_MODEL = "decisionnode-moe"
13
+ DEFAULT_TIMEOUT = 10.0
14
+ DEFAULT_MAX_RETRIES = 2
15
+ DEFAULT_CONCURRENCY = 8
16
+ # Seconds for one image PUT to storage, apart from `timeout`: a 10 MB image
17
+ # on a slow uplink needs more than an API call does.
18
+ DEFAULT_UPLOAD_TIMEOUT = 60.0
19
+ # Seconds for one document PUT to storage: a document is up to 50 MB.
20
+ DOCUMENT_UPLOAD_TIMEOUT = 120.0
21
+ # Seconds for one attempt of a document write with `wait: true`, when the
22
+ # caller set no timeout: the server holds such a call up to 10 s.
23
+ WAIT_TIMEOUT = 20.0
24
+ # Seconds for one attempt of a decision whose boosters name ocr, when the
25
+ # caller set no timeout: reading text takes longer than a decision does.
26
+ OCR_TIMEOUT = 30.0
27
+ private_constant :OCR_TIMEOUT, :WAIT_TIMEOUT
28
+
29
+ RETRY_STATUSES = ([408, 429] + (500..599).to_a).freeze
30
+ BACKOFF_START = 0.5
31
+ BACKOFF_CAP = 8.0
32
+ # The longest wait between attempts the client accepts from the server, in seconds.
33
+ RETRY_AFTER_CAP = 60.0
34
+
35
+ # run_batch: the shortest wait between two reads of a running batch, the
36
+ # default wait, and how long it waits for a batch to end, in seconds.
37
+ MIN_POLL_INTERVAL = 10.0
38
+ DEFAULT_POLL_INTERVAL = 60.0
39
+ DEFAULT_BATCH_TIMEOUT = 26 * 3600.0
40
+
41
+ # supervise: the backoff before connecting again, in seconds.
42
+ RECONNECT_START = 1.0
43
+ RECONNECT_CAP = 30.0
44
+
45
+ # The client class under a second name.
46
+ Client = self
47
+
48
+ UNSET = Object.new.freeze
49
+ private_constant :UNSET
50
+
51
+ # The questions sent by #gate.
52
+ GATE_QUESTIONS = {
53
+ "risk" => Questions.score(
54
+ "How much harm could this action cause if it is wrong?",
55
+ %w[none low moderate high severe]
56
+ ),
57
+ "touches_production" => Questions.truth(
58
+ "Does this action change a production system, production data or anything customers use?"
59
+ ),
60
+ "verdict" => Questions.choice(
61
+ "Should this action run as it is, wait for a person to confirm it, or be blocked?",
62
+ {
63
+ "allow" => "safe to run without asking",
64
+ "confirm" => "a person should confirm it first",
65
+ "block" => "should not run"
66
+ }
67
+ )
68
+ }.each_value { |q| q.each_value(&:freeze).freeze }.freeze
69
+
70
+ attr_reader :base_url, :model, :timeout, :max_retries, :inline_threshold, :upload_timeout
71
+
72
+ # The Batch API, a DecisionNode::Batches.
73
+ attr_reader :batches
74
+
75
+ # Collections of faces and knowledge bases, a DecisionNode::Collections.
76
+ attr_reader :collections
77
+
78
+ # Per-key usage, a DecisionNode::KeyUsage.
79
+ attr_reader :usage
80
+
81
+ # Decisions-only keys for devices, a DecisionNode::Keys.
82
+ attr_reader :keys
83
+
84
+ # Every argument is optional.
85
+ #
86
+ # api_key:: the API key; else DECISIONNODE_API_KEY.
87
+ # base_url:: else DECISIONNODE_BASE_URL, else https://api.decisionnode.com.
88
+ # model:: the default model; else DECISIONNODE_MODEL, else decisionnode-moe.
89
+ #
90
+ # An empty string for api_key, base_url or model counts as not given.
91
+ # The base URL must use https; http is accepted only for localhost,
92
+ # 127.0.0.1 and ::1, so a key never travels unencrypted.
93
+ # timeout:: seconds for a whole attempt, from sending the request to the
94
+ # last byte of the body (default 10, and 30 for a decision whose
95
+ # boosters name ocr; a timeout given here is used as given for
96
+ # every call). Must be above 0.
97
+ # max_retries:: retries after the first attempt (default 2). Must be 0 or more.
98
+ # headers:: extra headers sent with every API request and on a session's
99
+ # handshake (never on an image upload to storage).
100
+ # inline_threshold:: bytes; a request's local images that total less go
101
+ # inline as base64, at or above it they are uploaded
102
+ # (default DEFAULT_INLINE_THRESHOLD, 524,288). 0 always uploads.
103
+ # upload_timeout:: seconds for one image PUT to storage (default 60), and for
104
+ # one document PUT (default 120), apart from `timeout`. Must be
105
+ # above 0.
106
+ # http_client:: a NetHTTPTransport built with your own options, or another
107
+ # transport with the interface of NetHTTPTransport#call.
108
+ # logger:: a Logger; when given, each attempt is logged at debug level
109
+ # (method, path, status, request id and timing, never the body).
110
+ #
111
+ # A client is safe to share between threads and reuses its connections.
112
+ def initialize(api_key: nil, base_url: nil, model: nil, timeout: nil, max_retries: nil,
113
+ headers: nil, inline_threshold: nil, upload_timeout: nil, http_client: nil, logger: nil)
114
+ @api_key = given(api_key) || env("DECISIONNODE_API_KEY")
115
+ @base_url = checked_base_url((given(base_url) || env("DECISIONNODE_BASE_URL") || DEFAULT_BASE_URL).chomp("/"))
116
+ @model = given(model) || env("DECISIONNODE_MODEL") || DEFAULT_MODEL
117
+ @timeout = timeout.nil? ? DEFAULT_TIMEOUT : checked_timeout(timeout)
118
+ @timeout_given = !timeout.nil?
119
+ @max_retries = max_retries.nil? ? DEFAULT_MAX_RETRIES : checked_max_retries(max_retries)
120
+ @inline_threshold = inline_threshold.nil? ? DEFAULT_INLINE_THRESHOLD : checked_threshold(inline_threshold)
121
+ @upload_timeout = upload_timeout.nil? ? DEFAULT_UPLOAD_TIMEOUT : checked_timeout(upload_timeout, "upload_timeout")
122
+ @document_upload_timeout = upload_timeout.nil? ? DOCUMENT_UPLOAD_TIMEOUT : @upload_timeout
123
+ @headers = (headers || {}).to_h { |k, v| [k.to_s, v.to_s] }.freeze
124
+ @http = checked_http_client(http_client) || NetHTTPTransport.new
125
+ @logger = logger
126
+ @sleeper = ->(seconds) { sleep(seconds) }
127
+ @clock = -> { Time.now.to_f }
128
+ @uploads = UploadCache.new(clock: -> { @clock.call })
129
+ @held = {}
130
+ @inflight = {}
131
+ @inflight_lock = Mutex.new
132
+ @batches = Batches.new(self)
133
+ @collections = Collections.new(self)
134
+ @usage = KeyUsage.new(self)
135
+ @keys = Keys.new(self)
136
+ end
137
+
138
+ def inspect
139
+ "#<#{self.class.name} base_url=#{@base_url.inspect} model=#{@model.inspect}>"
140
+ end
141
+
142
+ # Sends one request and returns a Decision.
143
+ #
144
+ # state:: what is judged: a String or any JSON value (default "").
145
+ # questions:: question id to question (a builder's result or a wire-form Hash).
146
+ # images:: an Array of Image (or wire-form Hashes), up to 16.
147
+ # videos:: an Array of Video (or wire-form Hashes), up to 4, each sent as
148
+ # its frames.
149
+ # model:: overrides the client's model for this call (nil or "" uses the
150
+ # client's). A fine-tuned model's name ("decisionnode-moe@acme/fraud",
151
+ # or pinned, ".../fraud:3") is sent as given.
152
+ # boosters:: an Array of boosters that read the request's images before the
153
+ # decision: names ("age"), Booster objects or wire-form Hashes, sent
154
+ # in this order. Their readings come back in Decision#evidence.
155
+ # fallback:: true or false, sent only when given. Left out, the API's default
156
+ # (true) applies: a decisionnode-moe request that no ready
157
+ # worker can take at once is answered by Flash, and
158
+ # Decision#fallback names both by their pinned ids. false: such a request gets the 529
159
+ # OverloadedError instead, retried like every 529.
160
+ #
161
+ # The request is checked before anything is sent: a fault the API would
162
+ # refuse raises BadRequestError or ValidationError with no request made.
163
+ # Boosters are not checked locally: the API answers for them.
164
+ def decide(state: UNSET, questions: nil, images: nil, videos: nil, model: nil, boosters: nil, fallback: nil)
165
+ decision_call("/v1/decide", state, questions, images, videos, model, boosters, fallback).first
166
+ end
167
+
168
+ # Runs several #decide calls, at most `concurrency` at a time. Each request is
169
+ # a Hash of #decide's arguments. Returns one entry per request, in input
170
+ # order: a Decision, or the error that request ended with. One failure does
171
+ # not stop the others. Each entry is a DecisionResult.
172
+ #
173
+ # Every request is checked before any is sent: a concurrency below 1 or a
174
+ # request #decide would refuse as an argument raises ArgumentError, and a
175
+ # request the API would refuse raises its BadRequestError or ValidationError,
176
+ # with nothing sent. Images uploaded for one request are reused by the others.
177
+ def decide_many(requests, concurrency: DEFAULT_CONCURRENCY)
178
+ limit = begin
179
+ Integer(concurrency)
180
+ rescue TypeError, FloatDomainError
181
+ raise ArgumentError, "concurrency must be a whole number of at least 1, got #{concurrency.inspect}"
182
+ end
183
+ raise ArgumentError, "concurrency must be at least 1, got #{limit}" if limit < 1
184
+ raise ArgumentError, "requests must be an Array of Hashes of decide's arguments" unless requests.respond_to?(:to_a)
185
+
186
+ calls = requests.to_a.each_with_index.map do |item, i|
187
+ prepared_call(**decide_args(item))
188
+ rescue ArgumentError => e
189
+ raise ArgumentError, "decide_many: request #{i}: #{e.message}"
190
+ end
191
+ results = Array.new(calls.size)
192
+ return results if calls.empty?
193
+
194
+ queue = Queue.new
195
+ calls.each_index { |i| queue << i }
196
+ workers = Array.new([limit, calls.size].min) do
197
+ Thread.new do
198
+ loop do
199
+ i = begin
200
+ queue.pop(true)
201
+ rescue ThreadError
202
+ break
203
+ end
204
+ results[i] = result_for { send_prepared("/v1/decide", calls[i]).first }
205
+ end
206
+ end
207
+ end
208
+ workers.each(&:join)
209
+ results
210
+ end
211
+
212
+ # Asks one `truth` question (id "check") and returns its TruthAnswer.
213
+ # An answer that is missing or malformed raises ServerError, since a
214
+ # TruthAnswer is promised; #decide would return an UnknownAnswer instead.
215
+ def check(state: UNSET, instructions: nil, if_true: nil, if_false: nil, images: nil, videos: nil, model: nil,
216
+ boosters: nil, fallback: nil)
217
+ question = Questions.truth(instructions, if_true: if_true, if_false: if_false)
218
+ helper_answer("check", TruthAnswer, state, question, images, videos, model, boosters, fallback)
219
+ end
220
+
221
+ # Asks one `choice` question (id "classify") and returns its ChoiceAnswer.
222
+ # An answer that is missing or malformed raises ServerError.
223
+ def classify(state: UNSET, instructions: nil, options: nil, images: nil, videos: nil, model: nil, boosters: nil,
224
+ fallback: nil)
225
+ helper_answer("classify", ChoiceAnswer, state, Questions.choice(instructions, options), images, videos, model,
226
+ boosters, fallback)
227
+ end
228
+
229
+ # Asks one `score` question (id "score") and returns its ScoreAnswer.
230
+ # An answer that is missing or malformed raises ServerError.
231
+ def score(state: UNSET, instructions: nil, levels: nil, images: nil, videos: nil, model: nil, boosters: nil,
232
+ fallback: nil)
233
+ helper_answer("score", ScoreAnswer, state, Questions.score(instructions, levels), images, videos, model, boosters,
234
+ fallback)
235
+ end
236
+
237
+ # Asks one `number` question (id "number") and returns its NumberAnswer.
238
+ # `min`, `max` and `step` left out take the server's defaults (0, 255, 1).
239
+ # An answer that is missing or malformed raises ServerError.
240
+ def number(state: UNSET, instructions: nil, min: nil, max: nil, step: nil, images: nil, videos: nil, model: nil,
241
+ boosters: nil, fallback: nil)
242
+ question = Questions.number(instructions, min: min, max: max, step: step)
243
+ helper_answer("number", NumberAnswer, state, question, images, videos, model, boosters, fallback)
244
+ end
245
+
246
+ # Judges an action before it runs. Sends three questions: "risk" (score),
247
+ # "touches_production" (truth) and "verdict" (choice of allow, confirm or block).
248
+ def gate(action:, context: nil, images: nil, videos: nil, model: nil, boosters: nil, fallback: nil)
249
+ state = { "action" => action }
250
+ state["context"] = context unless context.nil?
251
+ decide(state: state, questions: GATE_QUESTIONS, images: images, videos: videos, model: model, boosters: boosters,
252
+ fallback: fallback)
253
+ end
254
+
255
+ # Lists the models with their published limits, as ModelInfo entries. The
256
+ # client keeps the list: a limit only `/v1/models` states (a session's
257
+ # `max_window`, a batch's `requests_per_batch`, a model without number
258
+ # questions) is then checked before sending.
259
+ def models
260
+ body, = request("GET", "/v1/models", nil)
261
+ list = body.is_a?(Hash) && body["models"].is_a?(Array) ? body["models"].grep(Hash) : []
262
+ @held = list.to_h { |m| [m["name"], m] }.freeze
263
+ list.map { |m| ModelInfo.new(m) }
264
+ end
265
+
266
+ # Sends `body` to POST /v1/decide exactly as given and returns the parsed
267
+ # response body. Use it for API fields this version does not know yet.
268
+ # Nothing is checked and no image is uploaded.
269
+ def decide_raw(body)
270
+ parsed, = request("POST", "/v1/decide", body)
271
+ parsed
272
+ end
273
+
274
+ # Opens a session (`POST /v1/sessions`) and returns a Session: one state and
275
+ # a fixed set of questions, answered again for every frame sent on its
276
+ # socket. Only the arguments given are sent, and `model` always is. A
277
+ # session takes no boosters.
278
+ #
279
+ # A 429 `session_limit_exceeded` (the key holds its most open sessions) is
280
+ # never retried and raises SessionLimitError, a RateLimitError.
281
+ def open_session(questions:, state: nil, instructions: nil, model: nil, window: nil, ttl_seconds: nil)
282
+ body = open_body(questions: questions, state: state, instructions: instructions, model: model,
283
+ window: window, ttl_seconds: ttl_seconds)
284
+ Checks.session_open!(body, limits: limits_for(body["model"]))
285
+ raw, = request("POST", "/v1/sessions", encode(body), encoded: true, policy: :session_open)
286
+ Session.new(self, raw)
287
+ end
288
+
289
+ CHECKED_CALLS = %w[decide open_session batches.create batches.add].freeze
290
+
291
+ # Runs every local check a call would run before sending, alone: it raises
292
+ # exactly what the call would raise for this request (held /v1/models limits
293
+ # included) and returns nil when the request would be sent. Nothing is sent
294
+ # and nothing is uploaded.
295
+ #
296
+ # dn.check_request("decide", state: "...", questions: { q: DecisionNode.truth("?") })
297
+ # dn.check_request(:open_session, { questions: questions, window: 128 }) # raises ValidationError
298
+ #
299
+ # method:: "decide", "open_session", "batches.create" or "batches.add" (a
300
+ # String or Symbol).
301
+ # request:: the call's keyword arguments, as a Hash or as keywords. For
302
+ # the batch calls, `requests:`; a `batch_id:` plays no part.
303
+ # Frames are checked with Session#check_frame.
304
+ def check_request(method, request = nil, **args)
305
+ name = method.to_s
306
+ raise ArgumentError, "check_request takes #{CHECKED_CALLS.join(', ')}, got #{method.inspect}" unless CHECKED_CALLS.include?(name)
307
+ raise ArgumentError, "request must be a Hash of the call's arguments" unless request.nil? || request.is_a?(Hash)
308
+
309
+ kw = (request || {}).merge(args).to_h { |k, v| [k.to_sym, v] }
310
+ case name
311
+ when "decide"
312
+ unknown = kw.keys - DECIDE_ARGS
313
+ raise ArgumentError, "unknown argument(s) #{unknown.join(', ')} for #{name}" unless unknown.empty?
314
+
315
+ prepared_call(**kw)
316
+ when "open_session"
317
+ body = open_body(**kw)
318
+ Checks.session_open!(body, limits: limits_for(body["model"]))
319
+ else
320
+ raise ArgumentError, "requests must be an Array of Hashes" unless kw.fetch(:requests, []).respond_to?(:to_a)
321
+
322
+ check_lines(kw.fetch(:requests, []).to_a, allow_empty: name == "batches.create")
323
+ end
324
+ nil
325
+ end
326
+
327
+ # Runs one long-lived feed through sessions, reopening them as the close
328
+ # codes say, and hands every Reply and FrameError to `on_reply` (or the
329
+ # block). Latest wins: each reading is sent as it comes, never queued
330
+ # behind an unanswered one.
331
+ #
332
+ # body:: open_session's arguments, as a Hash.
333
+ # feed:: an Enumerable of readings, each a Hash of Session#send's arguments.
334
+ #
335
+ # A session closed with 1001, 1006, 1011, 4404, 4408, 4409 or 4410 is
336
+ # reopened with the same body before the next reading (after a capped backoff
337
+ # with jitter, 1 s to 30 s, except after 4408 and 4410); 1013 connects again
338
+ # to the same session after the backoff; any other code stops it. When the
339
+ # feed ends it waits for the outstanding replies, ends the session and
340
+ # returns the last SessionClosed, or nil when none was seen; when it stops on
341
+ # a code it returns that SessionClosed.
342
+ def supervise(body, feed, on_reply = nil, max_sessions: 10, drain_timeout: 3.0, &block)
343
+ handler = on_reply || block
344
+ raise ArgumentError, "supervise needs on_reply or a block" if handler.nil?
345
+ raise ArgumentError, "body must be a Hash of open_session's arguments" unless body.is_a?(Hash)
346
+
347
+ Supervisor.new(self, body.to_h { |k, v| [k.to_sym, v] }, handler, max_sessions, drain_timeout).run(feed)
348
+ end
349
+
350
+ # Runs a whole batch and returns `[batch, results]`: the ended Batch and one
351
+ # BatchResult per request. Creates an empty draft, adds the requests (in
352
+ # calls of at most 10 MiB), finalizes it, then reads it every `poll_interval`
353
+ # seconds (or the server's Retry-After when longer) until it ends, and reads
354
+ # its results. If an add fails, the draft is cancelled and the error raised.
355
+ #
356
+ # poll_interval:: seconds, at least 10 (default 60).
357
+ # timeout:: seconds to wait for the batch to end (default 26 hours);
358
+ # past it TimeoutError is raised and the batch keeps running.
359
+ def run_batch(requests, poll_interval: DEFAULT_POLL_INTERVAL, timeout: DEFAULT_BATCH_TIMEOUT)
360
+ interval = checked_seconds(poll_interval, "poll_interval")
361
+ raise ArgumentError, "poll_interval must be at least #{MIN_POLL_INTERVAL.to_i} seconds" if interval < MIN_POLL_INTERVAL
362
+
363
+ limit = checked_seconds(timeout, "timeout")
364
+ raise ArgumentError, "requests must be an Array of Hashes" unless requests.respond_to?(:to_a)
365
+
366
+ list = requests.to_a
367
+ check_lines(list, allow_empty: true)
368
+ draft = @batches.create([])
369
+ begin
370
+ @batches.add(draft.id, list) unless list.empty?
371
+ rescue StandardError
372
+ begin
373
+ @batches.cancel(draft.id)
374
+ rescue DecisionNodeError
375
+ nil
376
+ end
377
+ raise
378
+ end
379
+ batch = @batches.finalize(draft.id)
380
+ headers = {}
381
+ waited = 0.0
382
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
383
+ until batch.ended?
384
+ elapsed = [waited, Process.clock_gettime(Process::CLOCK_MONOTONIC) - started].max
385
+ raise TimeoutError, "the batch #{batch.id} did not end within #{limit} s" if elapsed >= limit
386
+
387
+ server = retry_wait(headers)
388
+ wait = server && server > interval ? server : interval
389
+ @sleeper.call(wait)
390
+ waited += wait
391
+ batch, headers = @batches.get_with_headers(batch.id)
392
+ end
393
+ [batch, @batches.results(batch.id)]
394
+ end
395
+
396
+ # Closes idle connections.
397
+ def close
398
+ @http.close if @http.respond_to?(:close)
399
+ end
400
+
401
+ private
402
+
403
+ LOOPBACK_HOSTS = %w[localhost 127.0.0.1 ::1].freeze
404
+ private_constant :LOOPBACK_HOSTS
405
+
406
+ # Raised inside an attempt when its whole-attempt deadline passes.
407
+ # An Exception, not a StandardError, so a transport's own rescue does not swallow it.
408
+ class AttemptDeadline < Exception; end
409
+ private_constant :AttemptDeadline
410
+
411
+ # A request checked and ready to send: the parts of its body, with its
412
+ # images still to be placed (inline or uploaded) when it is sent. Videos
413
+ # are in their wire form already: frames always go inline.
414
+ Prepared = Struct.new(:state, :questions, :images, :videos, :model, :encoded, :boosters, :fallback)
415
+ private_constant :Prepared
416
+
417
+ def checked_base_url(url)
418
+ uri = begin
419
+ URI.parse(url)
420
+ rescue URI::InvalidURIError => e
421
+ raise ArgumentError, "base_url is not a valid URL: #{e.message}"
422
+ end
423
+ host = uri.host.to_s.delete_prefix("[").delete_suffix("]").downcase
424
+ raise ArgumentError, "base_url must be an absolute URL with a host, got #{url.inspect}" if host.empty?
425
+
426
+ case uri.scheme&.downcase
427
+ when "https" then url
428
+ when "http"
429
+ return url if LOOPBACK_HOSTS.include?(host)
430
+
431
+ raise ArgumentError, "base_url must use https (http is accepted only for localhost, 127.0.0.1 and ::1), " \
432
+ "got #{url.inspect}"
433
+ else
434
+ raise ArgumentError, "base_url must use https, got #{url.inspect}"
435
+ end
436
+ end
437
+
438
+ def checked_timeout(value, name = "timeout")
439
+ seconds = Float(value)
440
+ raise ArgumentError, "#{name} must be above 0 seconds, got #{value.inspect}" unless seconds.positive? && seconds.finite?
441
+
442
+ seconds
443
+ rescue TypeError
444
+ raise ArgumentError, "#{name} must be a number of seconds, got #{value.inspect}"
445
+ end
446
+
447
+ def checked_seconds(value, name)
448
+ raise ArgumentError, "#{name} must be a number of seconds, got #{value.inspect}" unless value.is_a?(Numeric)
449
+
450
+ seconds = value.to_f
451
+ raise ArgumentError, "#{name} must be a finite number above 0, got #{value.inspect}" unless seconds.positive? && seconds.finite?
452
+
453
+ seconds
454
+ end
455
+
456
+ def checked_max_retries(value)
457
+ count = value.is_a?(Float) && value != value.floor ? nil : Integer(value)
458
+ raise ArgumentError, "max_retries must be a whole number of 0 or more, got #{value.inspect}" if count.nil? || count.negative?
459
+
460
+ count
461
+ rescue TypeError, FloatDomainError
462
+ raise ArgumentError, "max_retries must be a whole number of 0 or more, got #{value.inspect}"
463
+ end
464
+
465
+ def checked_threshold(value)
466
+ unless value.is_a?(Integer) && !value.negative?
467
+ raise ArgumentError, "inline_threshold must be a whole number of bytes, 0 or more, got #{value.inspect}"
468
+ end
469
+
470
+ value
471
+ end
472
+
473
+ # A transport passed in must never follow a redirect, so the key is never
474
+ # sent to another URL. It says so by answering false to follow_redirects?;
475
+ # one that answers true, or does not answer, is refused.
476
+ def checked_http_client(http)
477
+ return nil if http.nil?
478
+ raise ArgumentError, "http_client must respond to call(method, url, headers, body, timeout)" unless http.respond_to?(:call)
479
+
480
+ unless http.respond_to?(:follow_redirects?) && http.follow_redirects? == false
481
+ raise ArgumentError, "http_client must never follow redirects and say so: follow_redirects? must return false"
482
+ end
483
+
484
+ http
485
+ end
486
+
487
+ # A value counts as given unless it is nil or an empty string.
488
+ def given(value)
489
+ return nil if value.nil?
490
+
491
+ text = value.to_s
492
+ text.empty? ? nil : text
493
+ end
494
+
495
+ def env(name)
496
+ value = ENV.fetch(name, nil)
497
+ value.nil? || value.empty? ? nil : value
498
+ end
499
+
500
+ DECIDE_ARGS = %i[state questions images videos model boosters fallback].freeze
501
+ private_constant :DECIDE_ARGS
502
+
503
+ def decide_args(item)
504
+ raise ArgumentError, "each request must be a Hash of decide's arguments, got #{item.class}" unless item.is_a?(Hash)
505
+
506
+ args = item.to_h { |k, v| [k.to_sym, v] }
507
+ unknown = args.keys - DECIDE_ARGS
508
+ raise ArgumentError, "unknown argument(s) #{unknown.join(', ')}; decide takes #{DECIDE_ARGS.join(', ')}" unless unknown.empty?
509
+
510
+ args
511
+ end
512
+
513
+ # The question map with String ids and String top-level keys in each
514
+ # question, checked against the API's fixed rules.
515
+ def prepared_questions(questions)
516
+ if questions.nil? || !questions.respond_to?(:each_pair) || questions.empty?
517
+ raise ArgumentError, "questions must hold at least one question"
518
+ end
519
+
520
+ # A question that is not a Hash is left for the checks, which refuse it as the API does.
521
+ questions.each_pair.to_h do |id, q|
522
+ [id.to_s, !q.is_a?(Hash) || q.keys.all?(String) ? q : q.transform_keys(&:to_s)]
523
+ end
524
+ end
525
+
526
+ # The body of POST /v1/sessions: only the fields given, and the model always.
527
+ def open_body(questions:, state: nil, instructions: nil, model: nil, window: nil, ttl_seconds: nil)
528
+ qs = prepared_questions(questions)
529
+ body = { "model" => given(model) || @model }
530
+ body["instructions"] = instructions unless instructions.nil?
531
+ body["state"] = state unless state.nil?
532
+ body["questions"] = qs
533
+ body["window"] = window unless window.nil?
534
+ body["ttl_seconds"] = ttl_seconds unless ttl_seconds.nil?
535
+ body
536
+ end
537
+
538
+ # The /v1/models entry of a model, when this client holds it, with the name
539
+ # the request uses: the entry of that name, else the entry that states the
540
+ # name as its `alias` or its pinned `version`. A name the held list does not
541
+ # state holds nothing, and its checks are left to the server.
542
+ def limits_for(model)
543
+ name = given(model) || @model
544
+ entry = @held[name] || @held.each_value.find { |m| m["alias"] == name || m["version"] == name }
545
+ entry ? entry.merge("model" => name) : {}
546
+ end
547
+
548
+ # Every fixed rule of a request before it is sent: the body (its images and
549
+ # videos as placeholders, so the nesting counts them) with the counts of
550
+ # its images and videos, then the images, then the videos.
551
+ def check_body(body, images, videos = [])
552
+ probe = body
553
+ probe = probe.merge("images" => images.map { |img| { "id" => image_id(img) } }) unless images.empty?
554
+ probe = probe.merge("videos" => Array.new(videos.size) { { "id" => "", "frames" => [{}] } }) unless videos.empty?
555
+ limits = limits_for(body["model"])
556
+ Checks.request!(probe, limits: limits, counts: Checks.count_entries(images, videos, limits: limits))
557
+ Checks.images!(images, limits: limits)
558
+ Checks.videos!(videos, image_ids: images.map { |img| image_id(img) })
559
+ end
560
+
561
+ def image_id(img)
562
+ img.is_a?(Image) ? img.id : img["id"] || img[:id]
563
+ end
564
+
565
+ # The image list as given: an Array of Image or wire-form Hashes.
566
+ def prepared_images(images)
567
+ return [] if images.nil?
568
+ raise ArgumentError, "images must be an Array of DecisionNode::Image" unless images.is_a?(Array)
569
+
570
+ images.each do |img|
571
+ next if img.is_a?(Image) || img.is_a?(Hash)
572
+
573
+ raise ArgumentError, "each image must be a DecisionNode::Image or a wire-form Hash, got #{img.class}"
574
+ end
575
+ images
576
+ end
577
+
578
+ # The video list as given: an Array of Video or wire-form Hashes.
579
+ def prepared_videos(videos)
580
+ return [] if videos.nil?
581
+ raise ArgumentError, "videos must be an Array of DecisionNode::Video" unless videos.is_a?(Array)
582
+
583
+ videos.each do |video|
584
+ next if video.is_a?(Video) || video.is_a?(Hash)
585
+
586
+ raise ArgumentError, "each video must be a DecisionNode::Video or a wire-form Hash, got #{video.class}"
587
+ end
588
+ videos
589
+ end
590
+
591
+ # The wire form of a request's videos, or nil when it has none.
592
+ def video_wires(videos)
593
+ videos.empty? ? nil : videos.map { |v| v.is_a?(Video) ? v.to_h : v }
594
+ end
595
+
596
+ # The wire form of a request's boosters in the order given, or nil when it
597
+ # names none (nil or an empty Array), so the body then has no `boosters` key.
598
+ # A Booster becomes its wire form; anything else is sent as given, since
599
+ # boosters are the API's to judge.
600
+ def booster_wires(boosters)
601
+ return nil if boosters.nil? || (boosters.is_a?(Array) && boosters.empty?)
602
+ return boosters unless boosters.is_a?(Array)
603
+
604
+ boosters.map { |b| b.is_a?(Booster) ? b.wire : b }
605
+ end
606
+
607
+ # The fallback argument: nil (left out of the request), true or false.
608
+ def checked_fallback(value)
609
+ return value if value.nil? || value == true || value == false
610
+
611
+ raise ArgumentError, "fallback must be true or false, got #{value.inspect}"
612
+ end
613
+
614
+ # Checks a request and keeps what is needed to send it. A request without
615
+ # local images is encoded once here.
616
+ def prepared_call(state: UNSET, questions: nil, images: nil, videos: nil, model: nil, boosters: nil, fallback: nil)
617
+ fallback = checked_fallback(fallback)
618
+ qs = prepared_questions(questions)
619
+ imgs = prepared_images(images)
620
+ vids = prepared_videos(videos)
621
+ state = state.equal?(UNSET) || state.nil? ? "" : state
622
+ model = given(model) || @model
623
+ boosted = booster_wires(boosters)
624
+ check_body(body_for(model, state, qs, nil, nil, boosted, fallback), imgs, vids)
625
+ wired = video_wires(vids)
626
+ if imgs.any? { |img| img.is_a?(Image) && img.local? }
627
+ # The body as it will be sent, an image to upload named by a placeholder
628
+ # id of the real length, so nothing is uploaded to measure it. A value
629
+ # that cannot be serialised fails here too.
630
+ projected, = image_wires(imgs, project: true)
631
+ Checks.body_size!(encode(body_for(model, state, qs, projected, wired, boosted, fallback)))
632
+ Prepared.new(state, qs, imgs, wired, model, nil, boosted, fallback)
633
+ else
634
+ wires = imgs.empty? ? nil : imgs.map { |img| img.is_a?(Image) ? img.to_h : img }
635
+ data = encode(body_for(model, state, qs, wires, wired, boosted, fallback))
636
+ Checks.body_size!(data)
637
+ Prepared.new(state, qs, imgs, wired, model, data, boosted, fallback)
638
+ end
639
+ end
640
+
641
+ def body_for(model, state, questions, images, videos = nil, boosters = nil, fallback = nil)
642
+ body = { "model" => model, "state" => state, "questions" => questions }
643
+ body["images"] = images unless images.nil?
644
+ body["videos"] = videos unless videos.nil?
645
+ body["boosters"] = boosters unless boosters.nil?
646
+ body["fallback"] = fallback unless fallback.nil?
647
+ body
648
+ end
649
+
650
+ def decision_call(path, state, questions, images, videos, model, boosters, fallback)
651
+ call = prepared_call(state: state, questions: questions, images: images, videos: videos, model: model,
652
+ boosters: boosters, fallback: fallback)
653
+ send_prepared(path, call)
654
+ end
655
+
656
+ # Sends a prepared request; returns the Decision and the HTTP status. A
657
+ # request that named uploads the SDK placed, answered with a stale-upload
658
+ # 400, is sent once more with the images uploaded again (not a retry).
659
+ def send_prepared(path, call)
660
+ ocr = ocr_options(call.boosters)
661
+ if call.encoded
662
+ body, headers, status = request("POST", path, call.encoded, encoded: true, **ocr)
663
+ return [Decision.new(body, headers), status]
664
+ end
665
+
666
+ 2.times do |attempt|
667
+ wires, placed = image_wires(call.images)
668
+ data = encode(body_for(call.model, call.state, call.questions, wires.empty? ? nil : wires, call.videos, call.boosters,
669
+ call.fallback))
670
+ begin
671
+ body, headers, status = request("POST", path, data, encoded: true, **ocr)
672
+ return [Decision.new(body, headers), status]
673
+ rescue BadRequestError => e
674
+ raise unless attempt.zero? && !placed.empty? && STALE_UPLOAD.include?(e.error_type)
675
+
676
+ @uploads.drop(placed)
677
+ end
678
+ end
679
+ end
680
+
681
+ # A decision whose boosters name ocr waits 30 s per attempt unless the
682
+ # caller set a timeout, and is never retried after a timeout: the server
683
+ # finishes that attempt and may bill it. Other decisions: no options.
684
+ def ocr_options(boosters)
685
+ list = boosters.is_a?(Array) ? boosters : [boosters]
686
+ names_ocr = list.any? { |b| booster_name(b) == "ocr" }
687
+ # A request naming knowledge keeps the client's timeout: that booster has
688
+ # its own budget of 800 ms.
689
+ return {} unless names_ocr && !list.any? { |b| booster_name(b) == "knowledge" }
690
+
691
+ { policy: :ocr, timeout: @timeout_given ? nil : OCR_TIMEOUT }
692
+ end
693
+
694
+ def booster_name(booster)
695
+ booster = booster.wire if booster.is_a?(Booster)
696
+ case booster
697
+ when Hash then (booster["name"] || booster[:name]).to_s
698
+ when String, Symbol then booster.to_s
699
+ end
700
+ end
701
+
702
+ # The typed answer a helper promises, or a ServerError when it is missing
703
+ # or malformed.
704
+ def helper_answer(id, klass, state, question, images, videos, model, boosters, fallback)
705
+ decision, status = decision_call("/v1/decide", state, { id => question }, images, videos, model, boosters, fallback)
706
+ answer = decision.answers[id]
707
+ return answer if answer.is_a?(klass)
708
+
709
+ what = answer.nil? ? "has no answer" : "has a malformed answer"
710
+ raise ServerError.new("the response #{what} to the #{id.inspect} question",
711
+ status: status, request_id: decision.request_id,
712
+ reason: decision.headers["x-decisionnode-error"],
713
+ body: decision.raw, headers: decision.headers)
714
+ end
715
+
716
+ # One entry of #decide_many: a Decision or a DecisionNodeError. An error of
717
+ # any other class is wrapped, with the original as its cause.
718
+ def result_for
719
+ yield
720
+ rescue DecisionNodeError => e
721
+ e
722
+ rescue StandardError => e
723
+ begin
724
+ raise DecisionNodeError, "the request failed: #{e.message} (#{e.class})"
725
+ rescue DecisionNodeError => wrapped
726
+ wrapped
727
+ end
728
+ end
729
+
730
+ # ---- sessions
731
+
732
+ # Waits between attempts, polls and reconnects; tests replace @sleeper.
733
+ def pause(seconds)
734
+ @sleeper.call(seconds)
735
+ end
736
+
737
+ # The `image` of a session frame: inline under the threshold, else by
738
+ # upload id; a URL is refused, since frames take bytes or an upload.
739
+ def frame_image(image)
740
+ return image if image.is_a?(Hash)
741
+ raise ArgumentError, "image must be a DecisionNode::Image, got #{image.class}" unless image.is_a?(Image)
742
+
743
+ wires, = image_wires([image])
744
+ wires.first.reject { |k, _| k == "id" }
745
+ end
746
+
747
+ def session_stream(url)
748
+ raise AuthenticationError, "no API key: pass api_key: or set DECISIONNODE_API_KEY" if @api_key.nil?
749
+ raise ArgumentError, "the session has no stream_url" if url.nil?
750
+
751
+ headers = { "Authorization" => "Bearer #{@api_key}", "User-Agent" => "decisionnode-ruby/#{VERSION}" }
752
+ options = @http.respond_to?(:session_options) ? @http.session_options.to_h : {}
753
+ proxy = options.fetch(:proxy, :env)
754
+ proxy = URI.parse(proxy) if proxy.is_a?(String)
755
+ SessionStream.new(url, headers.merge(@headers), @timeout, proxy: proxy, tls: options.fetch(:tls, nil) || {})
756
+ end
757
+
758
+ # ---- batches
759
+
760
+ def line_parts(req, index)
761
+ raise ArgumentError, "batch request #{index + 1} must be a Hash, got #{req.class}" unless req.is_a?(Hash)
762
+
763
+ args = req.to_h { |k, v| [k.to_s, v] }
764
+ checked_fallback(args["fallback"])
765
+ [args, prepared_questions(args["questions"]), prepared_images(args["images"]), prepared_videos(args["videos"])]
766
+ rescue ArgumentError => e
767
+ raise e if e.message.start_with?("batch request")
768
+
769
+ raise ArgumentError, "batch request #{index + 1}: #{e.message}"
770
+ end
771
+
772
+ # The requests of one create or add call, every line checked before any is
773
+ # sent: the custom_id rules, then each line as a decide request, then each
774
+ # line as it would be sent against the body limit.
775
+ def check_lines(requests, allow_empty:)
776
+ Checks.lines!(requests, allow_empty: allow_empty, limits: limits_for(nil))
777
+ requests.each_with_index do |req, i|
778
+ args, questions, images, videos = line_parts(req, i)
779
+ check_body(body_for(given(args["model"]) || @model, args["state"].nil? ? "" : args["state"], questions, nil, nil,
780
+ booster_wires(args["boosters"]), args["fallback"]), images, videos)
781
+ end
782
+ requests.each_with_index { |req, i| Checks.body_size!(encode(line_for(req, i, project: true))) }
783
+ nil
784
+ end
785
+
786
+ # One JSON Lines line: custom_id, model (always), state ("" when absent),
787
+ # questions, images, videos, boosters, fallback (only when given), then any
788
+ # other field as given.
789
+ def batch_line(req, index)
790
+ "#{encode(line_for(req, index))}\n"
791
+ end
792
+
793
+ # A batch line as a Hash. With project: true nothing is uploaded: an image
794
+ # to upload is named by a placeholder id of the real length.
795
+ def line_for(req, index, project: false)
796
+ args, questions, images, videos = line_parts(req, index)
797
+ line = { "custom_id" => args["custom_id"], "model" => given(args["model"]) || @model,
798
+ "state" => args["state"].nil? ? "" : args["state"], "questions" => questions }
799
+ line["images"] = image_wires(images, project: project).first unless images.empty?
800
+ line["videos"] = video_wires(videos) unless videos.empty?
801
+ boosted = booster_wires(args["boosters"])
802
+ line["boosters"] = boosted unless boosted.nil?
803
+ line["fallback"] = args["fallback"] unless args["fallback"].nil?
804
+ args.each { |k, v| line[k] = v unless line.key?(k) || %w[images videos boosters fallback].include?(k) }
805
+ line
806
+ end
807
+
808
+ # ---- transport
809
+
810
+ def encode(body)
811
+ JSON.generate(body, max_nesting: false) # the nesting limit is checked with the request
812
+ rescue JSON::GeneratorError, Encoding::UndefinedConversionError, SystemStackError => e
813
+ raise ArgumentError, "the request cannot be serialised as JSON: #{e.message}"
814
+ end
815
+
816
+ # Sends one API request with the retry policy and returns the parsed body
817
+ # (or the text with raw: true), the response headers and the status.
818
+ #
819
+ # policy:: :default retries 408, 429, 5xx, timeouts and connection errors;
820
+ # :mutation (batch create, add, finalize) only 429 and 529;
821
+ # :session_open never a 429 `session_limit_exceeded`;
822
+ # :ocr (a decision whose boosters name ocr) never a timeout, since
823
+ # the server finishes that attempt and may bill it;
824
+ # :idempotent (a call that carries its own Idempotency-Key, the
825
+ # same on every attempt) as :default, and also a 409
826
+ # `idempotency_in_progress` after its Retry-After.
827
+ # Under every policy a 429 `key_spend_cap_reached` is never retried.
828
+ # timeout:: seconds for each attempt in place of the client's timeout.
829
+ # extra_headers:: headers this call adds, after the client's own.
830
+ #
831
+ # With a block, a 2xx body is handed to the block line by line as it
832
+ # arrives (when the transport can stream), and the text returned is empty.
833
+ # A stream that fails after its first line is not retried.
834
+ def request(method, path, body, encoded: false, policy: :default, content_type: nil, raw: false, timeout: nil,
835
+ extra_headers: nil, &on_line)
836
+ raise AuthenticationError, "no API key: pass api_key: or set DECISIONNODE_API_KEY" if @api_key.nil?
837
+
838
+ data = body.nil? || encoded ? body : encode(body)
839
+ headers = {
840
+ "Authorization" => "Bearer #{@api_key}",
841
+ "User-Agent" => "decisionnode-ruby/#{VERSION}",
842
+ "Accept" => "application/json"
843
+ }
844
+ headers["Content-Type"] = content_type || "application/json" unless data.nil?
845
+ headers.merge!(@headers)
846
+ headers.merge!(extra_headers) if extra_headers
847
+ url = @base_url + path
848
+
849
+ attempt = 0
850
+ loop do
851
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
852
+ lines = on_line && @http.respond_to?(:stream) ? LineSplitter.new(on_line) : nil
853
+ begin
854
+ response = if lines
855
+ stream_once(method, url, headers, lines)
856
+ else
857
+ attempt_once(method, url, headers, data, timeout: timeout || @timeout)
858
+ end
859
+ rescue TimeoutError, ConnectionError => e
860
+ log(method, path, e.class.name.split("::").last, nil, started, attempt)
861
+ raise if attempt >= @max_retries || policy == :mutation || lines&.started?
862
+ raise if policy == :ocr && e.is_a?(TimeoutError)
863
+
864
+ @sleeper.call(backoff(attempt))
865
+ attempt += 1
866
+ next
867
+ end
868
+ log(method, path, response.status, response.headers["x-request-id"], started, attempt)
869
+
870
+ # Only 2xx is a success. Redirects are never followed, so the key is
871
+ # never sent to another URL or over plain HTTP: a 3xx is an error.
872
+ if (200..299).cover?(response.status)
873
+ if lines
874
+ lines.finish
875
+ return ["", response.headers, response.status]
876
+ end
877
+ response.body.to_s.each_line(&on_line) if on_line
878
+ return [on_line ? "" : response.body.to_s, response.headers, response.status] if raw || on_line
879
+
880
+ return [parse_success(response), response.headers, response.status]
881
+ end
882
+
883
+ error = error_for(response)
884
+ if attempt < @max_retries && retryable?(error, policy)
885
+ wait = retry_wait(response.headers)
886
+ @sleeper.call(wait.nil? ? backoff(attempt) : wait)
887
+ attempt += 1
888
+ next
889
+ end
890
+ raise error
891
+ end
892
+ end
893
+
894
+ # One streamed attempt: the transport hands a 2xx body over in chunks as it
895
+ # arrives, each read bounded by the timeout, so a results file of any size
896
+ # is read without holding it whole.
897
+ def stream_once(method, url, headers, lines)
898
+ response = @http.stream(method, url, headers, @timeout) { |chunk| lines << chunk }
899
+ unless response.is_a?(HTTPResponse)
900
+ raise ConnectionError, "the transport returned #{response.class}, not a DecisionNode::HTTPResponse"
901
+ end
902
+
903
+ response
904
+ end
905
+
906
+ # Cuts a stream of chunks into lines and hands each complete one on.
907
+ class LineSplitter
908
+ def initialize(on_line)
909
+ @on_line = on_line
910
+ @buffer = +""
911
+ @started = false
912
+ end
913
+
914
+ def <<(chunk)
915
+ @buffer << chunk.b
916
+ while (cut = @buffer.index("\n"))
917
+ line = @buffer.slice!(0, cut + 1)
918
+ @started = true
919
+ @on_line.call(line.force_encoding(Encoding::UTF_8))
920
+ end
921
+ end
922
+
923
+ def started?
924
+ @started
925
+ end
926
+
927
+ def finish
928
+ return if @buffer.empty?
929
+
930
+ @started = true
931
+ @on_line.call(@buffer.force_encoding(Encoding::UTF_8))
932
+ @buffer = +""
933
+ end
934
+ end
935
+ private_constant :LineSplitter
936
+
937
+ def retryable?(error, policy)
938
+ # An enrollment with no price set: a retry never cures it, and the API
939
+ # sends no Retry-After for it.
940
+ return false if error.status == 529 && error.details&.dig("reason") == "enrollment_unpriced"
941
+ # Knowledge whose documents wait on ingestion: an outage of ingestion,
942
+ # so the caller decides.
943
+ return false if error.status == 529 && error.details&.dig("reason") == "ingestion_delayed"
944
+ # A key past its daily spend cap: its Retry-After runs to the cap's reset
945
+ # at 00:00 UTC, so it is raised at once.
946
+ return false if error.status == 429 && error.error_type == "key_spend_cap_reached"
947
+
948
+ case policy
949
+ when :mutation then [429, 529].include?(error.status)
950
+ when :session_open then RETRY_STATUSES.include?(error.status) && !error.is_a?(SessionLimitError)
951
+ when :idempotent
952
+ RETRY_STATUSES.include?(error.status) || (error.status == 409 && error.error_type == "idempotency_in_progress")
953
+ else RETRY_STATUSES.include?(error.status)
954
+ end
955
+ end
956
+
957
+ # One attempt under one deadline: the timeout covers the whole attempt, from
958
+ # sending the request to the last byte of the body, whatever the transport
959
+ # does in between.
960
+ def attempt_once(method, url, headers, data, timeout: @timeout)
961
+ response = begin
962
+ Timeout.timeout(timeout, AttemptDeadline) { @http.call(method, url, headers, data, timeout) }
963
+ rescue AttemptDeadline
964
+ raise TimeoutError, "no complete response within #{timeout} s"
965
+ end
966
+ unless response.is_a?(HTTPResponse)
967
+ raise ConnectionError, "the transport returned #{response.class}, not a DecisionNode::HTTPResponse"
968
+ end
969
+ # A transport that followed a redirect reports the address it ended at.
970
+ if response.url && response.url != url
971
+ raise DecisionNodeError.new("the transport followed a redirect to another URL; redirects are never followed",
972
+ status: response.status, headers: response.headers)
973
+ end
974
+ response
975
+ end
976
+
977
+ def parse_success(response)
978
+ JSON.parse(response.body, max_nesting: false)
979
+ rescue JSON::ParserError, TypeError => e
980
+ raise ServerError.new("the response is not JSON: #{e.message}",
981
+ status: response.status, request_id: response.headers["x-request-id"],
982
+ headers: response.headers)
983
+ end
984
+
985
+ def backoff(attempt)
986
+ base = [BACKOFF_CAP, BACKOFF_START * (2**attempt)].min
987
+ base * (0.75 + (rand * 0.25))
988
+ end
989
+
990
+ # The wait the server asks for, in seconds: retry-after-ms, else Retry-After
991
+ # in seconds or as an HTTP date. Capped at RETRY_AFTER_CAP; nil when neither
992
+ # header holds a usable value.
993
+ def retry_wait(headers)
994
+ ms = number_value(headers["retry-after-ms"])
995
+ seconds = ms.nil? ? nil : ms / 1000.0
996
+ seconds ||= number_value(headers["retry-after"]) || http_date_wait(headers["retry-after"])
997
+ return nil if seconds.nil? || seconds.nan?
998
+
999
+ seconds.clamp(0.0, RETRY_AFTER_CAP)
1000
+ end
1001
+
1002
+ def http_date_wait(value)
1003
+ return nil if value.nil?
1004
+
1005
+ Time.httpdate(value.strip) - Time.now
1006
+ rescue ArgumentError
1007
+ nil
1008
+ end
1009
+
1010
+ def number_value(value)
1011
+ value.nil? ? nil : Float(value.strip)
1012
+ rescue ArgumentError
1013
+ nil
1014
+ end
1015
+
1016
+ # The text of an error status whose body is not JSON, the same in every
1017
+ # DecisionNode SDK and never the HTTP library's own wording.
1018
+ STATUS_TEXT = {
1019
+ 400 => "Bad Request", 401 => "Unauthorized", 402 => "Payment Required", 403 => "Forbidden",
1020
+ 404 => "Not Found", 405 => "Method Not Allowed", 408 => "Request Timeout", 409 => "Conflict",
1021
+ 410 => "Gone", 413 => "Content Too Large", 422 => "Unprocessable Content", 429 => "Too Many Requests",
1022
+ 500 => "Internal Server Error", 502 => "Bad Gateway", 503 => "Service Unavailable",
1023
+ 504 => "Gateway Timeout", 529 => "Overloaded"
1024
+ }.freeze
1025
+ private_constant :STATUS_TEXT
1026
+
1027
+ def error_for(response)
1028
+ body = begin
1029
+ JSON.parse(response.body.to_s, max_nesting: false)
1030
+ rescue JSON::ParserError
1031
+ # Not the API's envelope (an edge's or a proxy's page, or no body): the
1032
+ # class follows the status alone and the body is never read.
1033
+ return error_from(response.status, response.headers, nil,
1034
+ message: edge_message(response.status, response.headers))
1035
+ end
1036
+ error_from(response.status, response.headers, body)
1037
+ end
1038
+
1039
+ # "HTTP <status> <text>", then the request id when the response has one.
1040
+ def edge_message(status, headers)
1041
+ text = "HTTP #{status}"
1042
+ text += " #{STATUS_TEXT[status]}" if STATUS_TEXT.key?(status)
1043
+ request_id = headers["x-request-id"]
1044
+ request_id.nil? ? text : "#{text} (request id #{request_id})"
1045
+ end
1046
+
1047
+ # The error the API's answer gives: the class by status (and error_type),
1048
+ # with the fields read from the body and headers. `message` is given for a
1049
+ # body that is not JSON, which then plays no part.
1050
+ def error_from(status, headers, body, message: nil)
1051
+ detail = body.is_a?(Hash) ? body["detail"] : nil
1052
+ error_type = entries = category = nil
1053
+ case detail
1054
+ when Hash
1055
+ error_type = detail["error_type"]
1056
+ message = detail["message"]
1057
+ category = detail["category"]
1058
+ when String
1059
+ message = detail
1060
+ when Array
1061
+ entries = detail
1062
+ end
1063
+ kw = {
1064
+ status: status, error_type: error_type, request_id: headers["x-request-id"],
1065
+ reason: headers["x-decisionnode-error"], body: body, headers: headers
1066
+ }
1067
+
1068
+ case status
1069
+ when 400
1070
+ error_type == "max_tokens_exceeded" ? ContextLimitError.new(message, **kw) : BadRequestError.new(message, **kw)
1071
+ when 401 then AuthenticationError.new(message, **kw)
1072
+ when 402 then InsufficientCreditError.new(message, **kw)
1073
+ when 403
1074
+ if error_type == "refusal_error"
1075
+ RefusalError.new(message, category: category, safety: Safety.parse(headers["x-decisionnode-safety"]), **kw)
1076
+ else
1077
+ PermissionDeniedError.new(message, **kw)
1078
+ end
1079
+ when 404 then NotFoundError.new(message, **kw)
1080
+ when 413 then RequestTooLargeError.new(message, **kw)
1081
+ # The API's 422 with no list of entries (`invalid_text`) has an empty list.
1082
+ when 422 then ValidationError.new(message, entries: entries || (body.is_a?(Hash) ? [] : nil), **kw)
1083
+ when 429
1084
+ klass = error_type == "session_limit_exceeded" ? SessionLimitError : RateLimitError
1085
+ klass.new(message, retry_after: retry_wait(headers), **kw)
1086
+ when 529 then OverloadedError.new(message, retry_after: retry_wait(headers), **kw)
1087
+ when 500..599 then ServerError.new(message, **kw)
1088
+ # Every other status, 3xx included: the base class with the status.
1089
+ else DecisionNodeError.new(message, **kw)
1090
+ end
1091
+ end
1092
+
1093
+ def log(method, path, status, request_id, started, attempt)
1094
+ return unless @logger
1095
+
1096
+ ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round
1097
+ @logger.debug("decisionnode: #{method} #{path} -> #{status} request_id=#{request_id || '-'} " \
1098
+ "#{ms} ms attempt=#{attempt + 1}")
1099
+ end
1100
+ end