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,246 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "uri"
5
+
6
+ class DecisionNode
7
+ # A batch: every field of the batch object, by name or as `batch["field"]`.
8
+ # A batch states no price: the batch price is each model's
9
+ # `batch_price_factor` in `models`, and each line's charge is in its usage.
10
+ class Batch
11
+ FIELDS = %w[id state counters created_at finalized_at expires_at ended_at results_url
12
+ results_expire_at usage error].freeze
13
+ ENDED = %w[completed failed cancelled expired].freeze
14
+
15
+ # The batch object as received.
16
+ attr_reader :raw
17
+
18
+ FIELDS.each do |name|
19
+ define_method(name) { @raw[name] }
20
+ end
21
+
22
+ def initialize(raw)
23
+ @raw = raw.is_a?(Hash) ? raw : {}
24
+ end
25
+
26
+ def [](key)
27
+ @raw[key.to_s]
28
+ end
29
+
30
+ def to_h
31
+ @raw
32
+ end
33
+
34
+ # True once the batch is completed, failed, cancelled or expired.
35
+ def ended?
36
+ !ended_at.nil? || ENDED.include?(state)
37
+ end
38
+
39
+ def inspect
40
+ "#<#{self.class.name} #{id.inspect} state=#{state.inspect}>"
41
+ end
42
+ end
43
+
44
+ # One page of DecisionNode::Batches#list.
45
+ class BatchPage
46
+ # The batches on this page, newest first, as Batch values.
47
+ attr_reader :batches
48
+ attr_reader :offset, :limit, :total
49
+ # The offset of the next page, or nil on the last page.
50
+ attr_reader :next_offset
51
+ attr_reader :raw
52
+
53
+ def initialize(raw)
54
+ @raw = raw.is_a?(Hash) ? raw : {}
55
+ @batches = (@raw["batches"] || []).map { |b| Batch.new(b) }
56
+ @offset = @raw["offset"]
57
+ @limit = @raw["limit"]
58
+ @total = @raw["total"]
59
+ @next_offset = @raw["next_offset"]
60
+ end
61
+
62
+ def to_h
63
+ @raw
64
+ end
65
+ end
66
+
67
+ # One line of a batch's results.
68
+ class BatchResult
69
+ attr_reader :custom_id
70
+ # The line's HTTP status (an Integer), or "expired" or "cancelled".
71
+ attr_reader :status
72
+ # The line's headers, names in lower case.
73
+ attr_reader :headers
74
+ # A Decision for a 200 line, parsed as #decide parses a response; else nil.
75
+ attr_reader :decision
76
+ # For an error line, the error #decide would have raised for that status
77
+ # and body, as a value (never raised); else nil.
78
+ attr_reader :error
79
+ # The line as received.
80
+ attr_reader :raw
81
+
82
+ def initialize(raw, error_builder)
83
+ @raw = raw.is_a?(Hash) ? raw : {}
84
+ @custom_id = @raw["custom_id"]
85
+ @status = @raw["status"]
86
+ @headers = (@raw["headers"].is_a?(Hash) ? @raw["headers"] : {}).to_h { |k, v| [k.to_s.downcase, v] }
87
+ body = @raw["body"]
88
+ if @status == 200 && body.is_a?(Hash)
89
+ @decision = Decision.new(body, @headers)
90
+ elsif @status.is_a?(Integer)
91
+ @error = error_builder.call(@status, @headers, body)
92
+ end
93
+ end
94
+
95
+ def ok?
96
+ !@decision.nil?
97
+ end
98
+ end
99
+
100
+ # The Batch API: decisions at the batch price. No time is set for a batch
101
+ # to run or finish; requests not answered within 24 hours of finalizing
102
+ # expire and are not charged.
103
+ # Reached as DecisionNode#batches.
104
+ #
105
+ # b = dn.batches.create([{ custom_id: "t1", state: "...", questions: { ... } }])
106
+ # dn.batches.finalize(b.id)
107
+ # dn.batches.get(b.id).state
108
+ # dn.batches.results(b.id).each { |r| r.decision || r.error }
109
+ #
110
+ # A request is a Hash of #decide's arguments plus `custom_id`; its images
111
+ # follow the client's inline threshold per line, and uploaded ones are held
112
+ # by the batch. Every request is checked locally before anything is sent.
113
+ #
114
+ # `get`, `list`, `results` and `cancel` are retried as every call is.
115
+ # `create`, `add` and `finalize` are retried only on 429 and 529, never after
116
+ # a timeout, a lost answer or a 5xx, since a repeat could make a second draft.
117
+ class Batches
118
+ # The most bytes one add call carries; a larger list is split, in order.
119
+ ADD_BYTES = 10_485_760
120
+ NDJSON = "application/x-ndjson"
121
+
122
+ def initialize(client)
123
+ @client = client
124
+ end
125
+
126
+ def inspect
127
+ "#<#{self.class.name}>"
128
+ end
129
+
130
+ # Creates a draft (`POST /v1/batches`) with these requests, or an empty
131
+ # draft without any. Returns the Batch.
132
+ def create(requests = [])
133
+ lines = lines_for(requests)
134
+ Batch.new(call("POST", "/v1/batches", lines.join, policy: :mutation, content_type: NDJSON))
135
+ end
136
+
137
+ # Adds requests to a draft (`POST /v1/batches/{id}/requests`), in calls of
138
+ # at most 10 MiB each, in order. Returns the Batch after the last call.
139
+ def add(batch_id, requests)
140
+ lines = lines_for(requests, allow_empty: false)
141
+
142
+ path = "/v1/batches/#{segment(batch_id)}/requests"
143
+ batch = nil
144
+ chunks(lines).each do |chunk|
145
+ batch = call("POST", path, chunk, policy: :mutation, content_type: NDJSON)
146
+ end
147
+ Batch.new(batch)
148
+ end
149
+
150
+ # Queues a draft to run (`POST /v1/batches/{id}/finalize`). A 409
151
+ # `invalid_batch_state` is resolved with #get: when the batch is no longer
152
+ # a draft (an earlier finalize went through), the batch is returned.
153
+ def finalize(batch_id)
154
+ Batch.new(call("POST", "/v1/batches/#{segment(batch_id)}/finalize", nil, policy: :mutation))
155
+ rescue DecisionNodeError => e
156
+ raise unless e.status == 409 && e.error_type == "invalid_batch_state"
157
+
158
+ batch = get(batch_id)
159
+ raise e if batch.state == "draft"
160
+
161
+ batch
162
+ end
163
+
164
+ # `GET /v1/batches/{id}`.
165
+ def get(batch_id)
166
+ Batch.new(call("GET", "/v1/batches/#{segment(batch_id)}", nil))
167
+ end
168
+
169
+ # One page of the key's batches, newest first (`GET /v1/batches`).
170
+ def list(offset: nil, limit: nil)
171
+ Checks.list!(offset, limit)
172
+ query = {}
173
+ query["offset"] = offset unless offset.nil?
174
+ query["limit"] = limit unless limit.nil?
175
+ path = query.empty? ? "/v1/batches" : "/v1/batches?#{URI.encode_www_form(query)}"
176
+ BatchPage.new(call("GET", path, nil))
177
+ end
178
+
179
+ # The results of an ended batch (`GET /v1/batches/{id}/results`), one
180
+ # BatchResult per request in upload order. The JSON Lines file is read
181
+ # from the network line by line as it arrives, however many lines it has.
182
+ # With a block, each BatchResult is handed to it as its line arrives and
183
+ # nil is returned; without one, an Array of every result.
184
+ def results(batch_id, &block)
185
+ builder = @client.method(:error_from)
186
+ out = block ? nil : []
187
+ handle = lambda do |line|
188
+ next if line.strip.empty?
189
+
190
+ result = BatchResult.new(JSON.parse(line, max_nesting: false), builder)
191
+ block ? block.call(result) : out << result
192
+ end
193
+ @client.__send__(:request, "GET", "/v1/batches/#{segment(batch_id)}/results", nil, raw: true, &handle)
194
+ out
195
+ rescue JSON::ParserError => e
196
+ raise ServerError.new("a results line is not JSON: #{e.message}")
197
+ end
198
+
199
+ # Cancels a batch (`POST /v1/batches/{id}/cancel`).
200
+ def cancel(batch_id)
201
+ Batch.new(call("POST", "/v1/batches/#{segment(batch_id)}/cancel", nil))
202
+ end
203
+
204
+ # `GET /v1/batches/{id}` with the response headers, for run_batch's waits.
205
+ def get_with_headers(batch_id)
206
+ body, headers, = @client.__send__(:request, "GET", "/v1/batches/#{segment(batch_id)}", nil)
207
+ [Batch.new(body), headers]
208
+ end
209
+
210
+ private
211
+
212
+ def call(method, path, body, policy: :default, content_type: nil)
213
+ parsed, = @client.__send__(:request, method, path, body, encoded: body.is_a?(String),
214
+ policy: policy, content_type: content_type)
215
+ parsed
216
+ end
217
+
218
+ def segment(batch_id)
219
+ id = batch_id.respond_to?(:id) ? batch_id.id : batch_id
220
+ raise ArgumentError, "a batch id is required" if id.nil? || id.to_s.empty?
221
+
222
+ URI.encode_www_form_component(id.to_s)
223
+ end
224
+
225
+ def lines_for(requests, allow_empty: true)
226
+ raise ArgumentError, "requests must be an Array of Hashes" unless requests.respond_to?(:to_a)
227
+
228
+ list = requests.to_a
229
+ @client.__send__(:check_lines, list, allow_empty: allow_empty) # every line checked first
230
+ list.each_with_index.map { |req, i| @client.__send__(:batch_line, req, i) }
231
+ end
232
+
233
+ def chunks(lines)
234
+ out = []
235
+ current = +""
236
+ lines.each do |line|
237
+ if !current.empty? && current.bytesize + line.bytesize > ADD_BYTES
238
+ out << current
239
+ current = +""
240
+ end
241
+ current << line
242
+ end
243
+ out << current
244
+ end
245
+ end
246
+ end