omnisocials 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 6d8e63bac3e6cf2774234d627061b6ef9d2f2555329374da55bc37b2dc912593
4
+ data.tar.gz: 1f432caac9857e978a539b9382aeb11390a5e02637c97725a84f5b7146f14206
5
+ SHA512:
6
+ metadata.gz: '079b0f69fe85f2317bae07bf58246d49a150d6a5afaed56aac9c4dc3111dfc6713ac250c12591f6f8428923e91003333377b004a6f64fe8eb786660b9fb75eca'
7
+ data.tar.gz: 5cced0edb928d0b5600a94ca7a28a8c3a809bfc58eee18ef89683c665f04327aee7d215c7982af068144165718e87468b237e698b7d36a273013cb6503df206e
data/README.md ADDED
@@ -0,0 +1,373 @@
1
+ # OmniSocials Ruby SDK
2
+
3
+ The official Ruby client for the [OmniSocials](https://omnisocials.com) Public API. Schedule and publish posts, upload media, and read analytics across Instagram, Facebook, LinkedIn, YouTube, TikTok, X, Pinterest, Bluesky, Threads, Mastodon, and Google Business.
4
+
5
+ Full API reference: [https://docs.omnisocials.com](https://docs.omnisocials.com)
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ gem install omnisocials
11
+ ```
12
+
13
+ Or in your Gemfile:
14
+
15
+ ```ruby
16
+ gem "omnisocials"
17
+ ```
18
+
19
+ Requires Ruby 3.0+. Zero runtime dependencies: built entirely on the Ruby standard library (Net::HTTP, OpenSSL, JSON).
20
+
21
+ ## Quickstart
22
+
23
+ ```ruby
24
+ require "omnisocials"
25
+
26
+ client = OmniSocials::Client.new # reads OMNISOCIALS_API_KEY from the environment
27
+ post = client.posts.create(content: "Hello from Ruby", channels: ["instagram", "linkedin"])
28
+ puts post["data"]["id"]
29
+ ```
30
+
31
+ ## Authentication
32
+
33
+ Create an API key in the OmniSocials app under **Settings -> API Keys**. Keys look like `omsk_live_...` (or `omsk_test_...` for test keys) and are bound to one workspace.
34
+
35
+ Pass the key explicitly or set the `OMNISOCIALS_API_KEY` environment variable:
36
+
37
+ ```ruby
38
+ require "omnisocials"
39
+
40
+ client = OmniSocials::Client.new(api_key: "omsk_live_...")
41
+ # or: export OMNISOCIALS_API_KEY=omsk_live_... and just OmniSocials::Client.new
42
+ ```
43
+
44
+ The client opens a fresh connection per request, so there is nothing to close. All responses are plain Hashes (parsed JSON, string keys), returned exactly as the API sends them.
45
+
46
+ ## Posts
47
+
48
+ ### Schedule a post
49
+
50
+ ```ruby
51
+ post = client.posts.create(
52
+ content: "Big announcement coming Friday",
53
+ channels: ["instagram", "facebook", "linkedin"],
54
+ scheduled_at: "2026-08-01T09:00:00Z",
55
+ media_urls: ["https://example.com/teaser.jpg"]
56
+ )
57
+ ```
58
+
59
+ Omit `scheduled_at` to create a draft, or use `create_and_publish` to publish immediately:
60
+
61
+ ```ruby
62
+ post = client.posts.create_and_publish(
63
+ content: "We are live!",
64
+ channels: ["x", "bluesky"]
65
+ )
66
+ ```
67
+
68
+ ### Post to specific platforms with platform options
69
+
70
+ `content` can be a per-platform Hash (with `default` as the fallback), and each platform has its own options Hash:
71
+
72
+ ```ruby
73
+ post = client.posts.create(
74
+ content: {
75
+ "default" => "New feature: best times to post",
76
+ "linkedin" => "We just shipped best times to post. Here is how it works."
77
+ },
78
+ channels: ["linkedin", "instagram", "youtube"],
79
+ media_urls: ["https://example.com/demo.mp4"],
80
+ scheduled_at: "2026-08-01T09:00:00Z",
81
+ instagram: { "share_to_feed" => true },
82
+ youtube: { "title" => "Best times to post, explained", "privacy_status" => "public" }
83
+ )
84
+ ```
85
+
86
+ Note that `linkedin` targets a personal LinkedIn profile and `linkedin_page` targets a LinkedIn company page. Both can be connected to the same workspace and posted to independently.
87
+
88
+ ### X thread example
89
+
90
+ Pass 2 to 25 `thread_parts` to publish a chained thread instead of a single tweet (each part is at most 280 characters). Bluesky and Mastodon support the same `thread_parts` shape:
91
+
92
+ ```ruby
93
+ post = client.posts.create(
94
+ content: "How we cut our API response times by 80%", # fallback text
95
+ channels: ["x"],
96
+ scheduled_at: "2026-08-01T15:00:00Z",
97
+ x: {
98
+ "thread_parts" => [
99
+ { "text" => "How we cut our API response times by 80%. A thread:" },
100
+ { "text" => "1/ We started by profiling every endpoint under real load." },
101
+ { "text" => "2/ The biggest win: batching analytics reads into one query." },
102
+ { "text" => "3/ Full write-up on the blog. Thanks for reading!" }
103
+ ]
104
+ }
105
+ )
106
+ ```
107
+
108
+ On update, passing `x: { "thread_parts" => nil }` clears the thread and reverts the post to single-tweet mode. Only top-level `nil` values are dropped from request bodies, so nested `nil` values like this one are sent as JSON `null`.
109
+
110
+ ### Other post operations
111
+
112
+ ```ruby
113
+ posts = client.posts.list(status: "scheduled", limit: 50, offset: 0)
114
+ post = client.posts.get("123")
115
+ client.posts.update("123", scheduled_at: "2026-08-02T10:00:00Z")
116
+ client.posts.publish("123") # publish a draft or scheduled post now
117
+ client.posts.delete("123") # returns nil (204)
118
+
119
+ # Recent posts fetched live from the connected platforms (including content
120
+ # published outside OmniSocials). Requires the analytics:read scope.
121
+ recent = client.posts.recent_platform(limit: 10, platforms: ["instagram", "tiktok"])
122
+ ```
123
+
124
+ ## Media
125
+
126
+ ### Upload from a URL (recommended, up to 1GB)
127
+
128
+ ```ruby
129
+ media = client.media.upload_from_url(
130
+ url: "https://example.com/promo.mp4",
131
+ name: "promo-august",
132
+ folder: "Campaigns"
133
+ )
134
+ media_id = media["data"]["id"]
135
+ ```
136
+
137
+ Files over 100MB are processed in the background: the response has `media["data"]["status"] == "processing"`; poll `client.media.get(media_id)` until it is `"ready"` before attaching it to a post.
138
+
139
+ ### Upload a local file (multipart, max 100MB)
140
+
141
+ ```ruby
142
+ media = client.media.upload(file: "/path/to/image.jpg", name: "hero-shot")
143
+ ```
144
+
145
+ `file` accepts a file path (String or Pathname), an IO (`File.open("...", "rb")`, StringIO), or raw bytes as a binary-encoded String (e.g. from `File.binread`).
146
+
147
+ Uploading a PDF splits it into image slides (max 20 pages) and returns `slides` plus a `media_ids` array. Pass all of `media_ids`, in order, to `posts.create` to publish the deck as a carousel (a native swipeable document on LinkedIn, an image carousel elsewhere).
148
+
149
+ Every upload response also includes a `compatibility` block listing any connected platforms that would reject the file.
150
+
151
+ ### Other media operations
152
+
153
+ ```ruby
154
+ files = client.media.list(search: "promo", folder_id: "42")
155
+ item = client.media.get("1001")
156
+ client.media.update("1001", name: "renamed", folder_id: "42")
157
+ client.media.update("1001", folder_id: nil) # move to the root ("All media")
158
+ client.media.delete("1001")
159
+
160
+ # Base64 upload (handy when you have bytes but no URL)
161
+ client.media.upload_from_base64(data: b64_string, mime_type: "image/png")
162
+
163
+ # Preflight compatibility BEFORE uploading
164
+ result = client.media.check(url: "https://example.com/huge.mp4")
165
+ puts result["summary"] unless result["compatible"]
166
+ ```
167
+
168
+ Note the difference between omitting `folder_id` (leave the folder unchanged) and passing `folder_id: nil` (move the file to the root). The same applies to `parent_id` on `folders.update`.
169
+
170
+ ### Large file uploads with a presigned URL
171
+
172
+ `create_upload_url` mints a one-time upload URL so any environment (a CI job, a sandbox, a script without the SDK) can upload a file without auth headers:
173
+
174
+ ```ruby
175
+ grant = client.media.create_upload_url
176
+ # grant contains: upload_url, upload_token, expires_in_seconds (600),
177
+ # method ("POST"), content_type ("multipart/form-data")
178
+ ```
179
+
180
+ POST the file to `grant["upload_url"]` as multipart form data (field name `file`) with no auth headers. The token is single-use and expires after 10 minutes. The response JSON contains the created media item with its `id` (or a `media_ids` array for a PDF).
181
+
182
+ ## Folders
183
+
184
+ ```ruby
185
+ folders = client.folders.list
186
+ folder = client.folders.create(name: "Campaigns")
187
+ sub = client.folders.create(name: "August", parent_id: folder["data"]["id"])
188
+ client.folders.update(sub["data"]["id"], name: "August 2026")
189
+ client.folders.update(sub["data"]["id"], parent_id: nil) # move to top level
190
+ client.folders.delete(folder["data"]["id"]) # files move to root, subfolders move up
191
+ ```
192
+
193
+ ## Accounts
194
+
195
+ ```ruby
196
+ accounts = client.accounts.list
197
+ accounts["data"].each do |account|
198
+ puts "#{account["platform"]}: #{account["username"]}"
199
+ end
200
+
201
+ account = client.accounts.get("instagram")
202
+ ```
203
+
204
+ ## Analytics
205
+
206
+ ```ruby
207
+ # One post
208
+ stats = client.analytics.post("123")
209
+
210
+ # Up to 100 posts in one request (avoids rate limit trouble when syncing)
211
+ bulk = client.analytics.posts(["123", "124", "125"])
212
+
213
+ # Workspace overview
214
+ overview = client.analytics.overview(period: "30d")
215
+
216
+ # Account-level stats (followers etc.)
217
+ accounts = client.analytics.accounts(platform: "instagram")
218
+ ```
219
+
220
+ ### Best times to post
221
+
222
+ ```ruby
223
+ best = client.analytics.best_times(platform: "instagram", timezone: "Europe/Amsterdam")
224
+ best["data"]["best_times"].each { |slot| puts slot }
225
+ ```
226
+
227
+ ## Locations (Instagram place tagging)
228
+
229
+ ```ruby
230
+ results = client.locations.search("Blue Bottle Coffee Oakland")
231
+ location_id = results["data"][0]["id"]
232
+ client.locations.validate(location_id)
233
+
234
+ client.posts.create(
235
+ content: "Great coffee here",
236
+ channels: ["instagram"],
237
+ media_urls: ["https://example.com/latte.jpg"],
238
+ location_id: location_id
239
+ )
240
+ ```
241
+
242
+ ## Webhooks
243
+
244
+ Subscribe to `post.scheduled`, `post.published`, and `post.failed` events:
245
+
246
+ ```ruby
247
+ webhook = client.webhooks.create(
248
+ url: "https://example.com/hooks/omnisocials",
249
+ events: ["post.published", "post.failed"]
250
+ )
251
+ secret = webhook["data"]["secret"] # only shown once, store it
252
+
253
+ client.webhooks.list
254
+ client.webhooks.get(webhook["data"]["id"])
255
+ client.webhooks.update(webhook["data"]["id"], is_active: false)
256
+ client.webhooks.rotate_secret(webhook["data"]["id"]) # new secret, shown once
257
+ client.webhooks.delete(webhook["data"]["id"])
258
+ ```
259
+
260
+ ### Verifying webhook signatures (Rails example)
261
+
262
+ Every delivery is signed with HMAC-SHA256. The `X-OmniSocials-Signature` header has the form `t=<unix>,v1=<hex>`, signed over `"{timestamp}.{raw_body}"`. Use `OmniSocials::Webhooks.verify` with the raw request body (do not parse it first):
263
+
264
+ ```ruby
265
+ class OmnisocialsWebhooksController < ApplicationController
266
+ skip_before_action :verify_authenticity_token
267
+
268
+ def create
269
+ payload = request.body.read # raw body, exactly as received
270
+ signature = request.headers["X-OmniSocials-Signature"]
271
+
272
+ begin
273
+ event = OmniSocials::Webhooks.verify(
274
+ payload: payload,
275
+ signature: signature,
276
+ secret: ENV.fetch("OMNISOCIALS_WEBHOOK_SECRET"),
277
+ tolerance: 300
278
+ )
279
+ rescue OmniSocials::WebhookVerificationError
280
+ return head :bad_request
281
+ end
282
+
283
+ if event["type"] == "post.published"
284
+ event["data"]["targets"].each do |target|
285
+ Rails.logger.info "#{target["platform"]} #{target["status"]} #{target["native_post_id"]}"
286
+ end
287
+ end
288
+
289
+ head :ok
290
+ end
291
+ end
292
+ ```
293
+
294
+ ### Verifying webhook signatures (plain Rack)
295
+
296
+ ```ruby
297
+ # config.ru
298
+ require "omnisocials"
299
+
300
+ app = lambda do |env|
301
+ request = Rack::Request.new(env)
302
+ payload = request.body.read
303
+ signature = env["HTTP_X_OMNISOCIALS_SIGNATURE"]
304
+
305
+ begin
306
+ event = OmniSocials::Webhooks.verify(
307
+ payload: payload,
308
+ signature: signature,
309
+ secret: ENV.fetch("OMNISOCIALS_WEBHOOK_SECRET")
310
+ )
311
+ rescue OmniSocials::WebhookVerificationError
312
+ next [400, { "content-type" => "application/json" }, ['{"error":"invalid signature"}']]
313
+ end
314
+
315
+ # handle event["type"] ...
316
+ [200, { "content-type" => "application/json" }, ['{"ok":true}']]
317
+ end
318
+
319
+ run app
320
+ ```
321
+
322
+ `OmniSocials::Webhooks.verify` uses a constant-time comparison, rejects timestamps older than `tolerance` seconds (default 300) to block replays, and returns the parsed event Hash on success. Respond with any 2xx within 10 seconds to acknowledge; deliveries are retried up to 3 times.
323
+
324
+ ## Error handling
325
+
326
+ Non-2xx responses raise typed exceptions with `status`, `code`, `message`, and the parsed `body`:
327
+
328
+ ```ruby
329
+ begin
330
+ client.posts.get("does-not-exist")
331
+ rescue OmniSocials::NotFoundError => e
332
+ puts "Gone: #{e.code} #{e.message}"
333
+ rescue OmniSocials::RateLimitError => e
334
+ puts "Slow down, retry in #{e.retry_after}s"
335
+ rescue OmniSocials::ValidationError => e
336
+ puts "Bad request: #{e.body}"
337
+ rescue OmniSocials::AuthenticationError
338
+ puts "Check your API key"
339
+ rescue OmniSocials::APIConnectionError
340
+ puts "Network problem"
341
+ rescue OmniSocials::APIError => e
342
+ puts "API error: #{e.status} #{e.message}"
343
+ end
344
+ ```
345
+
346
+ The full hierarchy: `OmniSocials::Error` is the base; `OmniSocials::APIError` covers all HTTP errors with subclasses `AuthenticationError` (401), `PermissionDeniedError` (403), `NotFoundError` (404), `ValidationError` (400/422), `RateLimitError` (429, with `#retry_after`), and `ServerError` (5xx); `APIConnectionError` covers network failures; `WebhookVerificationError` covers signature failures.
347
+
348
+ ## Rate limits
349
+
350
+ The API allows **100 requests per minute** per API key. Every response includes `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` headers. On 429 the SDK automatically waits for the `Retry-After` value and retries. When syncing analytics for many posts, prefer the bulk `client.analytics.posts([...])` endpoint (100 posts per request) over per-post calls.
351
+
352
+ ## Retries and timeouts
353
+
354
+ The SDK automatically retries on 429, 5xx, and connection errors with exponential backoff (0.5s, 1s, 2s, with jitter), honoring the `Retry-After` header when present. Other 4xx errors are never retried. Defaults: 2 retries, 30 second timeout. Both are configurable:
355
+
356
+ ```ruby
357
+ client = OmniSocials::Client.new(
358
+ api_key: "omsk_live_...",
359
+ timeout: 60, # seconds
360
+ max_retries: 5, # retries after the first attempt
361
+ base_url: "https://api.omnisocials.com/v1" # override for testing
362
+ )
363
+ ```
364
+
365
+ ## More
366
+
367
+ - Full API reference and guides: [https://docs.omnisocials.com](https://docs.omnisocials.com)
368
+ - MCP server for AI agents: `npx -y @omnisocials/mcp-server`
369
+ - Agent skills CLI: `npx skills add OmniSocials/omnisocials-agent-skills`
370
+
371
+ ## License
372
+
373
+ MIT
@@ -0,0 +1,249 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "openssl"
6
+ require "securerandom"
7
+ require "time"
8
+ require "uri"
9
+
10
+ module OmniSocials
11
+ # Synchronous client for the OmniSocials Public API.
12
+ #
13
+ # require "omnisocials"
14
+ #
15
+ # client = OmniSocials::Client.new # reads OMNISOCIALS_API_KEY from env
16
+ # # or: OmniSocials::Client.new(api_key: "omsk_live_...", base_url: ...,
17
+ # # timeout: ..., max_retries: ...)
18
+ #
19
+ # post = client.posts.create(
20
+ # content: "Hello from the SDK",
21
+ # channels: ["instagram", "linkedin"],
22
+ # scheduled_at: "2026-08-01T09:00:00Z"
23
+ # )
24
+ #
25
+ # Zero runtime dependencies: built on Net::HTTP, OpenSSL, and JSON from the
26
+ # Ruby standard library. A fresh connection is opened per request, so there
27
+ # is nothing to close.
28
+ class Client
29
+ DEFAULT_BASE_URL = "https://api.omnisocials.com/v1"
30
+ DEFAULT_TIMEOUT = 30.0
31
+ DEFAULT_MAX_RETRIES = 2
32
+ USER_AGENT = "omnisocials-ruby/#{VERSION}"
33
+
34
+ # Statuses that are retried automatically (alongside connection errors).
35
+ RETRY_STATUSES = [429, 500, 502, 503, 504].freeze
36
+
37
+ METHOD_CLASSES = {
38
+ "GET" => Net::HTTP::Get,
39
+ "POST" => Net::HTTP::Post,
40
+ "PATCH" => Net::HTTP::Patch,
41
+ "DELETE" => Net::HTTP::Delete
42
+ }.freeze
43
+
44
+ CONNECTION_ERRORS = [
45
+ Errno::ECONNREFUSED,
46
+ Errno::ECONNRESET,
47
+ Errno::EHOSTUNREACH,
48
+ Errno::ENETUNREACH,
49
+ Errno::EPIPE,
50
+ Errno::ETIMEDOUT,
51
+ SocketError,
52
+ EOFError,
53
+ IOError,
54
+ Net::OpenTimeout,
55
+ Net::ReadTimeout,
56
+ Net::WriteTimeout,
57
+ OpenSSL::SSL::SSLError,
58
+ Timeout::Error
59
+ ].freeze
60
+
61
+ attr_reader :api_key, :base_url, :timeout, :max_retries,
62
+ :posts, :media, :folders, :accounts, :analytics, :locations,
63
+ :webhooks
64
+
65
+ # api_key - API key (omsk_live_* / omsk_test_*). Falls back to the
66
+ # OMNISOCIALS_API_KEY environment variable. Raises
67
+ # OmniSocials::AuthenticationError when neither is set.
68
+ # base_url - API base URL (default https://api.omnisocials.com/v1).
69
+ # timeout - per-request timeout in seconds (default 30).
70
+ # max_retries - automatic retries after the first attempt (default 2),
71
+ # applied on 429, 5xx, and connection errors.
72
+ def initialize(api_key: nil, base_url: nil, timeout: DEFAULT_TIMEOUT,
73
+ max_retries: DEFAULT_MAX_RETRIES)
74
+ key = api_key.nil? || api_key.to_s.empty? ? ENV["OMNISOCIALS_API_KEY"] : api_key
75
+ if key.nil? || key.to_s.empty?
76
+ raise AuthenticationError.new(
77
+ "No API key provided. Pass api_key: ... to OmniSocials::Client.new " \
78
+ "or set the OMNISOCIALS_API_KEY environment variable. Create a key " \
79
+ "in the OmniSocials app under Settings -> API Keys.",
80
+ status: nil
81
+ )
82
+ end
83
+ @api_key = key
84
+ @base_url = (base_url || DEFAULT_BASE_URL).sub(%r{/+\z}, "")
85
+ @timeout = Float(timeout)
86
+ @max_retries = [max_retries.to_i, 0].max
87
+
88
+ @posts = Resources::Posts.new(self)
89
+ @media = Resources::Media.new(self)
90
+ @folders = Resources::Folders.new(self)
91
+ @accounts = Resources::Accounts.new(self)
92
+ @analytics = Resources::Analytics.new(self)
93
+ @locations = Resources::Locations.new(self)
94
+ @webhooks = Resources::Webhooks.new(self)
95
+ end
96
+
97
+ # GET /health - API health check (no scopes required).
98
+ def health
99
+ request("GET", "/health")
100
+ end
101
+
102
+ # Perform an HTTP request against the API with automatic retries.
103
+ #
104
+ # Returns the parsed response body (Hash/Array) as-is, the raw text for
105
+ # non-JSON responses, or nil for 204. Raises an OmniSocials::APIError
106
+ # subclass on non-2xx and OmniSocials::APIConnectionError when no
107
+ # response was ever received.
108
+ def request(method, path, query: nil, json: nil, multipart: nil)
109
+ uri = build_uri(path, query)
110
+ last_error = nil
111
+
112
+ (max_retries + 1).times do |attempt|
113
+ begin
114
+ response = perform(method, uri, json: json, multipart: multipart)
115
+ rescue *CONNECTION_ERRORS => e
116
+ last_error = e
117
+ break if attempt >= max_retries
118
+
119
+ sleep(Internal.backoff_delay(attempt, nil))
120
+ next
121
+ end
122
+
123
+ should_retry, retry_after = retry_info(response)
124
+ if should_retry && attempt < max_retries
125
+ sleep(Internal.backoff_delay(attempt, retry_after))
126
+ next
127
+ end
128
+ return handle_response(response)
129
+ end
130
+
131
+ raise APIConnectionError,
132
+ "Connection error while requesting #{method} #{uri}: " \
133
+ "#{last_error.class}: #{last_error.message}"
134
+ end
135
+
136
+ private
137
+
138
+ def build_uri(path, query)
139
+ uri = URI.parse("#{base_url}#{path}")
140
+ pairs = Internal.serialize_query(query)
141
+ uri.query = URI.encode_www_form(pairs) if pairs
142
+ uri
143
+ end
144
+
145
+ def perform(method, uri, json:, multipart:)
146
+ request_class = METHOD_CLASSES.fetch(method) do
147
+ raise ArgumentError, "Unsupported HTTP method: #{method}"
148
+ end
149
+ req = request_class.new(uri)
150
+ req["Authorization"] = "Bearer #{api_key}"
151
+ req["User-Agent"] = USER_AGENT
152
+ req["Accept"] = "application/json"
153
+
154
+ if json
155
+ req["Content-Type"] = "application/json"
156
+ req.body = JSON.generate(json)
157
+ elsif multipart
158
+ boundary = "----OmniSocialsRuby#{SecureRandom.hex(16)}"
159
+ req["Content-Type"] = "multipart/form-data; boundary=#{boundary}"
160
+ req.body = Internal.multipart_body(
161
+ boundary, multipart[:fields] || {}, multipart[:filename], multipart[:data]
162
+ )
163
+ end
164
+
165
+ http = Net::HTTP.new(uri.host, uri.port)
166
+ http.use_ssl = uri.scheme == "https"
167
+ http.open_timeout = timeout
168
+ http.read_timeout = timeout
169
+ http.write_timeout = timeout
170
+ http.start { |conn| conn.request(req) }
171
+ end
172
+
173
+ def retry_info(response)
174
+ status = response.code.to_i
175
+ if RETRY_STATUSES.include?(status) || status >= 500
176
+ [true, parse_retry_after(response, Internal.parse_body(response.body))]
177
+ else
178
+ [false, nil]
179
+ end
180
+ end
181
+
182
+ def handle_response(response)
183
+ status = response.code.to_i
184
+ return nil if status == 204
185
+ return Internal.parse_body(response.body) if status >= 200 && status < 300
186
+
187
+ raise error_from_response(response)
188
+ end
189
+
190
+ def error_from_response(response)
191
+ body = Internal.parse_body(response.body)
192
+ status = response.code.to_i
193
+
194
+ code = nil
195
+ message = "Request failed with status #{status}."
196
+ if body.is_a?(Hash)
197
+ error = body["error"]
198
+ if error.is_a?(Hash)
199
+ code = error["code"] if error["code"].is_a?(String)
200
+ message = error["message"] if error["message"].is_a?(String)
201
+ elsif body["message"].is_a?(String)
202
+ message = body["message"]
203
+ end
204
+ end
205
+
206
+ case status
207
+ when 401
208
+ AuthenticationError.new(message, status: status, code: code, body: body)
209
+ when 403
210
+ PermissionDeniedError.new(message, status: status, code: code, body: body)
211
+ when 404
212
+ NotFoundError.new(message, status: status, code: code, body: body)
213
+ when 400, 422
214
+ ValidationError.new(message, status: status, code: code, body: body)
215
+ when 429
216
+ RateLimitError.new(
217
+ message,
218
+ status: status, code: code, body: body,
219
+ retry_after: parse_retry_after(response, body)
220
+ )
221
+ else
222
+ if status >= 500
223
+ ServerError.new(message, status: status, code: code, body: body)
224
+ else
225
+ APIError.new(message, status: status, code: code, body: body)
226
+ end
227
+ end
228
+ end
229
+
230
+ def parse_retry_after(response, body)
231
+ header = response["retry-after"]
232
+ if header
233
+ stripped = header.strip
234
+ return [stripped.to_f, 0.0].max if stripped.match?(/\A-?\d+(\.\d+)?\z/)
235
+
236
+ begin
237
+ return [Time.httpdate(stripped) - Time.now, 0.0].max
238
+ rescue ArgumentError
239
+ # Not an HTTP-date either; fall through to the body.
240
+ end
241
+ end
242
+ if body.is_a?(Hash) && body["retry_after"].is_a?(Numeric)
243
+ return [body["retry_after"].to_f, 0.0].max
244
+ end
245
+
246
+ nil
247
+ end
248
+ end
249
+ end