vision_api 1.0.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.
@@ -0,0 +1,431 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "securerandom"
6
+ require "uri"
7
+
8
+ require_relative "errors"
9
+ require_relative "multipart"
10
+
11
+ module VisionAPI
12
+ DEFAULT_BASE_URL = "https://api.visionapi.io"
13
+
14
+ # The Vision API client.
15
+ #
16
+ # One object, one method per endpoint, plus the two things every integration ends up
17
+ # writing by hand: retry that honors +Retry-After+, and polling that knows when a task is
18
+ # done. Docs: https://docs.visionapi.io
19
+ #
20
+ # vision = VisionAPI::Client.new # reads ENV["VISION_API_KEY"]
21
+ # res = vision.analyze(file: "invoice.pdf", preset: "invoice")
22
+ # res["result"]["invoice_id"]["value"] # => "A-10422"
23
+ #
24
+ # Responses are plain hashes with the wire's +snake_case+ keys, because the response
25
+ # shape *is* the public contract and renaming it here would make
26
+ # https://docs.visionapi.io stop matching what you see in your editor.
27
+ class Client
28
+ RETRYABLE_STATUS = [429, 500, 502, 503].freeze
29
+
30
+ attr_reader :base_url, :timeout, :max_retries
31
+
32
+ # @param api_key [String, nil] your secret key; defaults to <tt>ENV["VISION_API_KEY"]</tt>.
33
+ # There is no publishable key and no test mode — this is a live spending credential,
34
+ # so keep it server-side. Create one at https://app.visionapi.io/dashboard/keys
35
+ # @param base_url [String, nil] override for a self-hosted or staging deployment
36
+ # @param timeout [Numeric] per-request deadline in seconds. The server kills a
37
+ # synchronous request at 60 s, so anything above that only covers upload time.
38
+ # @param max_retries [Integer] how many times to retry a *retryable* failure — 429,
39
+ # 500, 502 and network errors. Input errors and +insufficient_credits+ are never
40
+ # retried.
41
+ # @param auto_idempotency [Boolean] generate an +Idempotency-Key+ for every billable
42
+ # POST that does not carry one, so a retry replays the first response instead of
43
+ # paying twice
44
+ # @param headers [Hash] extra headers sent on every request
45
+ def initialize(api_key: nil, base_url: nil, timeout: 120, max_retries: 3,
46
+ auto_idempotency: true, headers: {})
47
+ @api_key = api_key || ENV.fetch("VISION_API_KEY", nil)
48
+ if @api_key.nil? || @api_key.empty?
49
+ raise UsageError, "No API key. Pass VisionAPI::Client.new(api_key: …) or set " \
50
+ "VISION_API_KEY. Create one at https://app.visionapi.io/dashboard/keys"
51
+ end
52
+
53
+ @base_url = (base_url || ENV["VISION_API_URL"] || DEFAULT_BASE_URL).sub(%r{/+\z}, "")
54
+ @timeout = timeout
55
+ @max_retries = max_retries
56
+ @auto_idempotency = auto_idempotency
57
+ @headers = headers
58
+ @user_agent = "visionapi-ruby/#{VisionAPI::VERSION} ruby/#{RUBY_VERSION}"
59
+ end
60
+
61
+ # ---------------------------------------------------------------- extraction
62
+
63
+ # Extracts structured data from one image or PDF and waits for the answer.
64
+ #
65
+ # Costs 1 credit per image, or 1 per *selected* PDF page. Failures cost nothing — the
66
+ # reservation is released in full on any non-2xx, so there is no compensating logic to
67
+ # write. Anything that might run past 60 s belongs on {#analyze_async}.
68
+ #
69
+ # @param file [String, Pathname, IO, String] a path, an open binary IO, or raw bytes
70
+ # @param file_url [String] a public URL for the API to fetch instead
71
+ # @param file_base64 [String] base64 bytes, with or without a +data:+ prefix
72
+ # @param preset [String] a catalog name, or +"auto"+ to have the API classify the file
73
+ # first (free)
74
+ # @param schema [Hash] custom fields, alone or on top of a preset
75
+ # @param schema_name [String] a schema saved in the dashboard; excludes +preset+ and +schema+
76
+ # @param pages [String] PDF page selection, e.g. +"1-3,7"+. You pay for selected pages only.
77
+ # @param language_hint [String] ISO 639-1 code, e.g. +"es"+
78
+ # @param detail [String] +"high"+ renders pages at higher resolution; same cost, slower
79
+ # @param output [String] +"text"+ returns raw OCR text instead of fields
80
+ # @param include_raw_text [Boolean] adds +full_text+ alongside +result+
81
+ # @param min_confidence [String] fields below this level come back nil, confidence kept
82
+ # @param idempotency_key [String] supply your own when the *caller* may retry
83
+ # @return [Hash] the full response body
84
+ def analyze(file: nil, file_url: nil, file_base64: nil, idempotency_key: nil,
85
+ timeout: nil, **params)
86
+ submit("/v1/analyze", file, file_url, file_base64, analyze_fields(**params),
87
+ idempotency_key, timeout)
88
+ end
89
+
90
+ # Submits an extraction to the queue and returns as soon as it is accepted.
91
+ #
92
+ # Use it for long PDFs, <tt>detail: "high"</tt>, or any batch you do not want to hold a
93
+ # connection open for. Poll with {#wait_for_task}, or pass +webhook_url+ — an HTTPS
94
+ # endpoint that does not resolve to a private address — and let the result come to you.
95
+ #
96
+ # @return [Hash] <tt>{"task_id" => …, "status" => "queued"}</tt>
97
+ def analyze_async(file: nil, file_url: nil, file_base64: nil, webhook_url: nil,
98
+ idempotency_key: nil, timeout: nil, **params)
99
+ fields = analyze_fields(**params).merge("async" => true, "webhook_url" => webhook_url)
100
+ submit("/v1/analyze", file, file_url, file_base64, fields, idempotency_key, timeout)
101
+ end
102
+
103
+ # {#analyze_async} followed by {#wait_for_task} — the shape most batch jobs want.
104
+ #
105
+ # @raise [TaskFailedError] the worker could not process the file (costs 0 credits)
106
+ # @raise [TaskTimeoutError] +max_wait+ elapsed while it was still running
107
+ def analyze_and_wait(poll_interval: 2, max_wait: 600, on_poll: nil, **params)
108
+ ref = analyze_async(**params)
109
+ wait_for_task(ref["task_id"], poll_interval: poll_interval, max_wait: max_wait, on_poll: on_poll)
110
+ end
111
+
112
+ # Asks up to 5 questions about one file.
113
+ #
114
+ # Priced exactly like an extraction — per image or per selected page — and the
115
+ # questions themselves are free. Branch on <tt>answer["verdict"]</tt>
116
+ # (+yes+ / +no+ / +uncertain+ / +n/a+) rather than parsing the prose.
117
+ def ask(questions:, file: nil, file_url: nil, file_base64: nil, pages: nil,
118
+ language_hint: nil, detail: nil, idempotency_key: nil, timeout: nil)
119
+ fields = ask_fields(questions, pages, language_hint, detail)
120
+ submit("/v1/ask", file, file_url, file_base64, fields, idempotency_key, timeout)
121
+ end
122
+
123
+ # Queues an {#ask} instead of waiting for it.
124
+ def ask_async(questions:, file: nil, file_url: nil, file_base64: nil, pages: nil,
125
+ language_hint: nil, detail: nil, webhook_url: nil, idempotency_key: nil,
126
+ timeout: nil)
127
+ fields = ask_fields(questions, pages, language_hint, detail)
128
+ .merge("async" => true, "webhook_url" => webhook_url)
129
+ submit("/v1/ask", file, file_url, file_base64, fields, idempotency_key, timeout)
130
+ end
131
+
132
+ # Identifies what a file is without paying to extract it.
133
+ #
134
+ # Metered at *1 credit per 10 calls* whatever the page count, because detection only
135
+ # ever reads page 1 — so nine calls out of ten report <tt>"credits_used" => 0</tt>.
136
+ # <tt>"recommended"</tt> is exactly what <tt>preset: "auto"</tt> would have run, so you
137
+ # can probe first and trust the answer.
138
+ #
139
+ # Reach for it when the *type* is the decision (routing a mixed inbox, refusing to
140
+ # spend on a 40-page PDF sight unseen); reach for <tt>preset: "auto"</tt> when you want
141
+ # the data and do not care which preset produced it.
142
+ def detect(file: nil, file_url: nil, file_base64: nil, detail: nil,
143
+ idempotency_key: nil, timeout: nil)
144
+ submit("/v1/detect", file, file_url, file_base64, { "detail" => detail },
145
+ idempotency_key, timeout)
146
+ end
147
+
148
+ # --------------------------------------------------------------------- tasks
149
+
150
+ # Status and, once finished, the result of an async task.
151
+ #
152
+ # Results stay retrievable for 7 days; after that this raises {ResultExpiredError}
153
+ # (the metadata survives, the payload does not).
154
+ def get_task(task_id, timeout: nil)
155
+ request(:get, "/v1/tasks/#{escape_segment(task_id)}", timeout: timeout)
156
+ end
157
+
158
+ # Polls until the task leaves +queued+/+processing+, then returns it.
159
+ #
160
+ # @param raise_on_failure [Boolean] when false, a failed task is returned as-is
161
+ # @param on_poll [Proc, nil] called with each non-final poll — a progress bar, a log line
162
+ # @raise [TaskFailedError] the task failed
163
+ # @raise [TaskTimeoutError] +max_wait+ elapsed; the task keeps running, so the id is
164
+ # still worth polling later
165
+ def wait_for_task(task_id, poll_interval: 2, max_wait: 600, raise_on_failure: true, on_poll: nil)
166
+ deadline = monotonic + max_wait
167
+
168
+ loop do
169
+ task = get_task(task_id)
170
+ case task["status"]
171
+ when "completed" then return task
172
+ when "failed"
173
+ return task unless raise_on_failure
174
+
175
+ raise TaskFailedError.new(task_id, task["error"])
176
+ end
177
+
178
+ raise TaskTimeoutError.new(task_id, max_wait) if monotonic + poll_interval > deadline
179
+
180
+ on_poll&.call(task)
181
+ sleep(poll_interval)
182
+ end
183
+ end
184
+
185
+ # ------------------------------------------------------- account & catalog
186
+
187
+ # Balance and the per-bucket breakdown. Buckets are spent in order:
188
+ # subscription → rollover → pack → welcome.
189
+ def credits(timeout: nil)
190
+ request(:get, "/v1/credits", timeout: timeout)
191
+ end
192
+
193
+ # One page of usage history, newest first. Metadata only — the file and the extracted
194
+ # values are never kept. Pass <tt>cursor: page["next_cursor"]</tt> for the next page.
195
+ def requests(limit: nil, cursor: nil, timeout: nil)
196
+ request(:get, "/v1/requests", query: { "limit" => limit, "cursor" => cursor }, timeout: timeout)
197
+ end
198
+
199
+ # Enumerates the whole history, one record at a time, paging as it goes.
200
+ #
201
+ # @return [Enumerator] lazy — +each_request.first(10)+ fetches one page
202
+ def each_request(limit: 100, &block)
203
+ return enum_for(:each_request, limit: limit) unless block_given?
204
+
205
+ cursor = nil
206
+ loop do
207
+ page = requests(limit: limit, cursor: cursor)
208
+ page["data"].each(&block)
209
+ cursor = page["next_cursor"]
210
+ break if cursor.nil? || cursor.empty?
211
+ end
212
+ end
213
+
214
+ # The preset catalog. No API key required.
215
+ #
216
+ # Pick a preset from here rather than from memory — presets are versioned, and the
217
+ # catalog is the source of truth for both the names and the fields.
218
+ def presets(timeout: nil)
219
+ request(:get, "/v1/presets", auth: false, timeout: timeout)["data"]
220
+ end
221
+
222
+ # One preset with its full field definitions. Read this before mapping field names.
223
+ def preset(name, timeout: nil)
224
+ request(:get, "/v1/presets/#{escape_segment(name)}", auth: false, timeout: timeout)
225
+ end
226
+
227
+ # ----------------------------------------------------------- saved schemas
228
+
229
+ # Every schema saved on this account.
230
+ def schemas(timeout: nil)
231
+ request(:get, "/v1/schemas", timeout: timeout)["data"]
232
+ end
233
+
234
+ def schema(name, timeout: nil)
235
+ request(:get, "/v1/schemas/#{escape_segment(name)}", timeout: timeout)
236
+ end
237
+
238
+ # Saves a preset + custom-field combination under a name, for later use as
239
+ # <tt>analyze(schema_name: …)</tt>. The definition is compiled before it is stored, so
240
+ # an invalid schema fails here rather than on the first extraction that uses it.
241
+ def create_schema(name, preset: nil, schema: nil, timeout: nil)
242
+ body = { "name" => name }
243
+ body["preset"] = preset if preset
244
+ body["schema"] = schema if schema
245
+ request(:post, "/v1/schemas", json: body, timeout: timeout)
246
+ end
247
+
248
+ # Replaces a saved schema in place.
249
+ def update_schema(name, preset: nil, schema: nil, timeout: nil)
250
+ body = {}
251
+ body["preset"] = preset if preset
252
+ body["schema"] = schema if schema
253
+ request(:put, "/v1/schemas/#{escape_segment(name)}", json: body, timeout: timeout)
254
+ end
255
+
256
+ def delete_schema(name, timeout: nil)
257
+ request(:delete, "/v1/schemas/#{escape_segment(name)}", timeout: timeout)
258
+ nil
259
+ end
260
+
261
+ private
262
+
263
+ # Percent-encodes one path segment.
264
+ #
265
+ # +URI.encode_uri_component+ arrived with Ruby 3.1 (uri 0.11) and this gem supports 3.0,
266
+ # where the nearest stdlib equivalent is +CGI.escape+ — which form-encodes a space as
267
+ # +"+"+. Inside a path segment a +"+"+ is a literal plus, not a space, so it has to go
268
+ # back to +%20+. The fallback encodes a few sub-delimiters the 3.1 path leaves alone;
269
+ # both decode to the same string server-side.
270
+ def escape_segment(value)
271
+ value = value.to_s
272
+ return URI.encode_uri_component(value) if URI.respond_to?(:encode_uri_component)
273
+
274
+ require "cgi"
275
+ CGI.escape(value).gsub("+", "%20")
276
+ end
277
+
278
+ def analyze_fields(preset: nil, schema: nil, schema_name: nil, pages: nil,
279
+ language_hint: nil, detail: nil, output: nil,
280
+ include_raw_text: nil, min_confidence: nil)
281
+ if schema_name && (preset || schema)
282
+ raise UsageError, "schema_name: is mutually exclusive with preset: and schema:."
283
+ end
284
+ if output == "text" && (preset || schema || schema_name)
285
+ raise UsageError, 'output: "text" cannot be combined with a preset or a schema.'
286
+ end
287
+
288
+ {
289
+ "preset" => preset,
290
+ "schema" => schema,
291
+ "schema_name" => schema_name,
292
+ "pages" => pages,
293
+ "language_hint" => language_hint,
294
+ "detail" => detail,
295
+ "output" => output,
296
+ "include_raw_text" => include_raw_text,
297
+ "min_confidence" => min_confidence
298
+ }
299
+ end
300
+
301
+ def ask_fields(questions, pages, language_hint, detail)
302
+ list = questions.is_a?(String) ? [questions] : Array(questions)
303
+ raise UsageError, "Provide at least one question." if list.empty?
304
+ raise UsageError, "At most 5 questions per request — received #{list.size}." if list.size > 5
305
+
306
+ {
307
+ "questions" => list,
308
+ "pages" => pages,
309
+ "language_hint" => language_hint,
310
+ "detail" => detail
311
+ }
312
+ end
313
+
314
+ def submit(path, file, file_url, file_base64, fields, idempotency_key, timeout)
315
+ given = { "file" => file, "file_url" => file_url, "file_base64" => file_base64 }.compact
316
+ raise UsageError, "Provide exactly one of file:, file_url: or file_base64:." if given.empty?
317
+ raise UsageError, "Provide exactly one file source — received #{given.keys.join(" and ")}." if given.size > 1
318
+
319
+ clean = fields.compact
320
+
321
+ if file
322
+ # Local bytes go out as multipart.
323
+ body, content_type = Multipart.encode(clean, Multipart.resolve(file))
324
+ request(:post, path, body: body, content_type: content_type,
325
+ idempotency_key: idempotency_key, billable: true, timeout: timeout)
326
+ else
327
+ # A URL or a base64 payload goes out as JSON — smaller, and no pointless encode
328
+ # step. Both forms use identical field names, so the endpoint cannot tell them apart.
329
+ payload = clean.dup
330
+ payload["file_url"] = file_url if file_url
331
+ payload["file_base64"] = file_base64 if file_base64
332
+ request(:post, path, json: payload, idempotency_key: idempotency_key,
333
+ billable: true, timeout: timeout)
334
+ end
335
+ end
336
+
337
+ def request(method, path, query: nil, body: nil, json: nil, content_type: nil,
338
+ idempotency_key: nil, billable: false, auth: true, timeout: nil)
339
+ uri = URI.parse(@base_url + path)
340
+ if query
341
+ pairs = query.reject { |_, v| v.nil? || v.to_s.empty? }
342
+ uri.query = URI.encode_www_form(pairs) unless pairs.empty?
343
+ end
344
+
345
+ if json
346
+ body = JSON.generate(json)
347
+ content_type = "application/json"
348
+ end
349
+
350
+ headers = {
351
+ "Accept" => "application/json",
352
+ "User-Agent" => @user_agent
353
+ }.merge(@headers)
354
+ headers["Authorization"] = "Bearer #{@api_key}" if auth
355
+ headers["Content-Type"] = content_type if content_type
356
+
357
+ # A retried upload without a key is a second charge, so the client supplies one for
358
+ # every billable POST. A failed attempt releases the key server-side, so retrying
359
+ # with the same one is exactly the intended use.
360
+ key = idempotency_key || (billable && @auto_idempotency ? SecureRandom.uuid : nil)
361
+ headers["Idempotency-Key"] = key if key
362
+
363
+ attempt = 0
364
+ loop do
365
+ begin
366
+ status, payload, response_headers = perform(method, uri, headers, body, timeout || @timeout)
367
+ rescue ConnectionError => e
368
+ raise e if attempt >= @max_retries
369
+
370
+ sleep(backoff(attempt))
371
+ attempt += 1
372
+ next
373
+ end
374
+
375
+ return nil if status == 204 || payload.nil? || payload.empty?
376
+
377
+ parsed = parse(payload)
378
+ return parsed if status.between?(200, 299)
379
+
380
+ error = ErrorFactory.build(status, parsed, response_headers)
381
+ if RETRYABLE_STATUS.include?(status) && error.retryable? && attempt < @max_retries
382
+ # 429 carries the server's own number. Honor it; do not invent a backoff.
383
+ sleep(error.is_a?(RateLimitError) ? error.retry_after : backoff(attempt))
384
+ attempt += 1
385
+ next
386
+ end
387
+ raise error
388
+ end
389
+ end
390
+
391
+ def perform(method, uri, headers, body, timeout)
392
+ klass = {
393
+ get: Net::HTTP::Get, post: Net::HTTP::Post,
394
+ put: Net::HTTP::Put, delete: Net::HTTP::Delete
395
+ }.fetch(method)
396
+
397
+ req = klass.new(uri)
398
+ headers.each { |name, value| req[name] = value }
399
+ req.body = body if body
400
+
401
+ http = Net::HTTP.new(uri.host, uri.port)
402
+ http.use_ssl = uri.scheme == "https"
403
+ http.open_timeout = timeout
404
+ http.read_timeout = timeout
405
+ http.write_timeout = timeout if http.respond_to?(:write_timeout=)
406
+
407
+ response = http.request(req)
408
+ lowered = response.each_header.to_h { |name, value| [name.downcase, value] }
409
+ [response.code.to_i, response.body, lowered]
410
+ rescue Net::OpenTimeout, Net::ReadTimeout => e
411
+ raise TimeoutError, "Request to #{uri} timed out after #{timeout}s (#{e.class})."
412
+ rescue SystemCallError, SocketError, OpenSSL::SSL::SSLError, IOError => e
413
+ raise ConnectionError, "Request to #{uri} failed: #{e.message}"
414
+ end
415
+
416
+ def parse(payload)
417
+ JSON.parse(payload)
418
+ rescue JSON::ParserError
419
+ payload
420
+ end
421
+
422
+ # Exponential backoff with jitter, for the failures that carry no Retry-After.
423
+ def backoff(attempt)
424
+ [0.5 * (2**attempt), 8.0].min * (0.75 + (rand * 0.5))
425
+ end
426
+
427
+ def monotonic
428
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
429
+ end
430
+ end
431
+ end
@@ -0,0 +1,239 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VisionAPI
4
+ # Base class for everything this library raises.
5
+ class Error < StandardError; end
6
+
7
+ # Bad arguments, caught before any HTTP call — and therefore before any credit moves.
8
+ class UsageError < Error; end
9
+
10
+ # The request never produced a response: DNS, TLS, a reset connection, a local timeout.
11
+ class ConnectionError < Error; end
12
+
13
+ # The client-side deadline elapsed. The server may still be working on the request.
14
+ class TimeoutError < ConnectionError; end
15
+
16
+ # A structured failure from the API.
17
+ #
18
+ # Every failure carries the same envelope — <tt>{"error": {code, message, details?}}</tt>
19
+ # — and +code+ is contract while +message+ is prose that changes. Rescue the class you
20
+ # mean, or switch on +code+; never branch on the message text.
21
+ class APIError < Error
22
+ # @return [Integer] the HTTP status
23
+ attr_reader :status
24
+ # @return [String] the stable machine-readable value from the envelope
25
+ attr_reader :code
26
+ # @return [Hash] whatever the endpoint attached: +required+/+available+ on 402,
27
+ # +request_id+ on 500, +max_pages+ on 413
28
+ attr_reader :details
29
+ # @return [Hash] the response headers, lower-cased
30
+ attr_reader :headers
31
+ # @return [String, nil] the correlation id. Quote it in support requests.
32
+ attr_reader :request_id
33
+
34
+ def initialize(status:, code:, message:, details: nil, headers: nil)
35
+ super("#{code}: #{message}")
36
+ @status = status
37
+ @code = code
38
+ @details = details || {}
39
+ @headers = headers || {}
40
+ @request_id = @details["request_id"] || @headers["x-request-id"]
41
+ end
42
+
43
+ # Whether the identical call could plausibly succeed on a second attempt. False for
44
+ # every input error, and for +insufficient_credits+ — no amount of retrying changes a
45
+ # balance.
46
+ def retryable?
47
+ RETRYABLE_CODES.include?(code)
48
+ end
49
+
50
+ # "too_many_tasks" is 429 but absent on purpose: it clears when the caller's own task
51
+ # finishes, so an automatic sleep-and-retry would block the very thing it waits for.
52
+ RETRYABLE_CODES = %w[rate_limited internal_error provider_error].freeze
53
+ end
54
+
55
+ # 400 — the call is malformed. Fix it; retrying will not help.
56
+ class InvalidRequestError < APIError; end
57
+
58
+ # 401 — key missing, unknown or revoked. https://app.visionapi.io/dashboard/keys
59
+ class AuthenticationError < APIError; end
60
+
61
+ # 403 — +forbidden+ or +email_not_verified+.
62
+ class PermissionDeniedError < APIError; end
63
+
64
+ # 402 — the account does not hold enough credits. Never retried: it cannot succeed.
65
+ class InsufficientCreditsError < APIError
66
+ # @return [Integer, nil] the credit cost of the request that was refused
67
+ def required = details["required"]
68
+
69
+ # @return [Integer, nil] the balance at the time of the refusal
70
+ def available = details["available"]
71
+ end
72
+
73
+ # 404 — wrong id, or it belongs to another account.
74
+ class NotFoundError < APIError; end
75
+
76
+ # 409 — a saved-schema name is taken, or an Idempotency-Key was reused differently.
77
+ class ConflictError < APIError; end
78
+
79
+ # 410 — past the 7-day window. Metadata survives, the payload does not. Re-submit the file.
80
+ class ResultExpiredError < APIError; end
81
+
82
+ # 413 — over 20 MB or over 50 pages. Split the input.
83
+ class PayloadTooLargeError < APIError; end
84
+
85
+ # 415 — the magic-byte check failed. Accepted: JPEG, PNG, WebP, TIFF, PDF.
86
+ class UnsupportedTypeError < APIError; end
87
+
88
+ # 422 — semantically invalid input: a bad page range, an encrypted PDF, an uncompilable schema.
89
+ class UnprocessableError < APIError; end
90
+
91
+ # 429 — sleep for #retry_after seconds. Do not invent a backoff.
92
+ class RateLimitError < APIError
93
+ # @return [Float] the server's own number, in seconds. Honor it rather than guessing.
94
+ def retry_after
95
+ header = headers["retry-after"]
96
+ value = header.to_f if header
97
+ return value if value&.positive?
98
+
99
+ detail = details["retry_after"]
100
+ return detail.to_f if detail.is_a?(Numeric) && detail.positive?
101
+
102
+ 2.0
103
+ end
104
+ end
105
+
106
+ # 429 — the account's concurrent async task cap is reached.
107
+ #
108
+ # A subclass of {RateLimitError} so `rescue RateLimitError` keeps working, but deliberately
109
+ # not in {RETRYABLE_CODES}: unlike a rate limit this does not clear on a timer, it clears
110
+ # when one of your own in-flight tasks finishes. Sleeping in the client would just hold the
111
+ # slot you are waiting for. Poll or await your outstanding tasks, then resubmit.
112
+ class TooManyTasksError < RateLimitError
113
+ # @return [Integer, nil] the cap that was hit, from `details.max`.
114
+ def max_tasks
115
+ value = details["max"]
116
+ value.to_i if value.is_a?(Numeric)
117
+ end
118
+ end
119
+
120
+ # 500 — quote #request_id if you report it. Safe to retry once.
121
+ class InternalError < APIError; end
122
+
123
+ # 502 — the model provider failed after its own retries. Retry with backoff.
124
+ class ProviderError < APIError; end
125
+
126
+ # 504 — past the 60 s synchronous limit.
127
+ #
128
+ # Retrying synchronously will time out again. Re-submit through {Client#analyze_async} —
129
+ # that is what <tt>details["suggestion"]</tt> means by <tt>"async"</tt>.
130
+ class SyncTimeoutError < APIError; end
131
+
132
+ # An async task came back +failed+. A failed task costs 0 credits.
133
+ class TaskFailedError < Error
134
+ attr_reader :task_id, :code, :details
135
+
136
+ def initialize(task_id, error)
137
+ error ||= {}
138
+ @task_id = task_id
139
+ @code = error["code"] || "unknown"
140
+ @details = error["details"] || {}
141
+ super("Task #{task_id} failed — #{@code}: #{error["message"] || "no detail"}")
142
+ end
143
+ end
144
+
145
+ # {Client#wait_for_task} gave up while the task was still running. The task itself keeps
146
+ # going, so the id is still worth polling later.
147
+ class TaskTimeoutError < Error
148
+ attr_reader :task_id
149
+
150
+ def initialize(task_id, waited)
151
+ @task_id = task_id
152
+ super("Task #{task_id} did not finish within #{waited.round}s.")
153
+ end
154
+ end
155
+
156
+ # A delivery could not be trusted. Reject it with a 400; do not parse the body.
157
+ class WebhookSignatureError < Error; end
158
+
159
+ # Maps the wire's error codes onto the classes above. An unrecognised code still produces
160
+ # an {APIError} with the code intact, so a new server-side code degrades to "unknown but
161
+ # structured" rather than to a crash.
162
+ module ErrorFactory
163
+ BY_CODE = {
164
+ "invalid_request" => InvalidRequestError,
165
+ "invalid_api_key" => AuthenticationError,
166
+ "unauthorized" => AuthenticationError,
167
+ "forbidden" => PermissionDeniedError,
168
+ "email_not_verified" => PermissionDeniedError,
169
+ "insufficient_credits" => InsufficientCreditsError,
170
+ "task_not_found" => NotFoundError,
171
+ "schema_not_found" => NotFoundError,
172
+ "not_found" => NotFoundError,
173
+ "conflict" => ConflictError,
174
+ "result_expired" => ResultExpiredError,
175
+ "file_too_large" => PayloadTooLargeError,
176
+ "page_limit_exceeded" => PayloadTooLargeError,
177
+ "unsupported_type" => UnsupportedTypeError,
178
+ "pdf_encrypted" => UnprocessableError,
179
+ "invalid_page_selection" => UnprocessableError,
180
+ "invalid_schema" => UnprocessableError,
181
+ "schema_field_conflict" => UnprocessableError,
182
+ "too_many_questions" => UnprocessableError,
183
+ "rate_limited" => RateLimitError,
184
+ "too_many_tasks" => TooManyTasksError,
185
+ "internal_error" => InternalError,
186
+ "provider_error" => ProviderError,
187
+ "sync_timeout" => SyncTimeoutError
188
+ }.freeze
189
+
190
+ # Status fallback, for a code this version of the client has not seen yet.
191
+ BY_STATUS = {
192
+ 400 => InvalidRequestError,
193
+ 401 => AuthenticationError,
194
+ 402 => InsufficientCreditsError,
195
+ 403 => PermissionDeniedError,
196
+ 404 => NotFoundError,
197
+ 409 => ConflictError,
198
+ 410 => ResultExpiredError,
199
+ 413 => PayloadTooLargeError,
200
+ 415 => UnsupportedTypeError,
201
+ 422 => UnprocessableError,
202
+ 429 => RateLimitError,
203
+ 500 => InternalError,
204
+ 502 => ProviderError,
205
+ 504 => SyncTimeoutError
206
+ }.freeze
207
+
208
+ module_function
209
+
210
+ # @return [APIError] the right subclass for a failed response
211
+ def build(status, body, headers)
212
+ envelope = body.is_a?(Hash) ? body["error"] : nil
213
+
214
+ if envelope.is_a?(Hash) && envelope["code"]
215
+ code = envelope["code"].to_s
216
+ message = envelope["message"].to_s
217
+ details = envelope["details"]
218
+ else
219
+ code = "http_#{status}"
220
+ message = if body.is_a?(String) && !body.empty?
221
+ body[0,
222
+ 500]
223
+ else
224
+ "The API returned HTTP #{status} with no error envelope."
225
+ end
226
+ details = nil
227
+ end
228
+
229
+ klass = BY_CODE[code] || BY_STATUS[status] || APIError
230
+ klass.new(
231
+ status: status,
232
+ code: code,
233
+ message: message,
234
+ details: details.is_a?(Hash) ? details : nil,
235
+ headers: headers
236
+ )
237
+ end
238
+ end
239
+ end