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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +19 -0
- data/LICENSE +21 -0
- data/README.md +451 -0
- data/lib/vision_api/client.rb +431 -0
- data/lib/vision_api/errors.rb +239 -0
- data/lib/vision_api/multipart.rb +106 -0
- data/lib/vision_api/result.rb +111 -0
- data/lib/vision_api/version.rb +5 -0
- data/lib/vision_api/webhook.rb +94 -0
- data/lib/vision_api.rb +31 -0
- metadata +104 -0
|
@@ -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
|