zeridion-flare 0.2.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/LICENSE.txt +21 -0
- data/README.md +175 -0
- data/lib/zeridion_flare/client.rb +298 -0
- data/lib/zeridion_flare/errors.rb +34 -0
- data/lib/zeridion_flare/version.rb +7 -0
- data/lib/zeridion_flare/webhook.rb +74 -0
- data/lib/zeridion_flare/worker/cancellation_token.rb +89 -0
- data/lib/zeridion_flare/worker/configuration.rb +97 -0
- data/lib/zeridion_flare/worker/errors.rb +44 -0
- data/lib/zeridion_flare/worker/executor.rb +98 -0
- data/lib/zeridion_flare/worker/heartbeat.rb +123 -0
- data/lib/zeridion_flare/worker/identity.rb +68 -0
- data/lib/zeridion_flare/worker/job.rb +85 -0
- data/lib/zeridion_flare/worker/job_context.rb +91 -0
- data/lib/zeridion_flare/worker/job_definition.rb +58 -0
- data/lib/zeridion_flare/worker/pool.rb +118 -0
- data/lib/zeridion_flare/worker/progress_slot.rb +43 -0
- data/lib/zeridion_flare/worker/recurring_job.rb +81 -0
- data/lib/zeridion_flare/worker/recurring_schedule.rb +51 -0
- data/lib/zeridion_flare/worker/registry.rb +100 -0
- data/lib/zeridion_flare/worker/runner.rb +313 -0
- data/lib/zeridion_flare/worker/worker.rb +211 -0
- data/lib/zeridion_flare/worker.rb +39 -0
- data/lib/zeridion_flare.rb +14 -0
- metadata +103 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 4ac177cb1ba3e09ec4d70f0d88e50027da6e1b8dd4e0547a13dbbd3020d56889
|
|
4
|
+
data.tar.gz: 7763c74ddba147b9fb9d03d48b94aba0d201476d487d83b943262bfda877aa0e
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: fc72570ecde04bc6f2d2715c0e277cde593d1177362383911233185da59549ad01eeee660ad487e7bff5f41999a19c7bbfb57dd0ad602a7f4ce98c0737fa50a2
|
|
7
|
+
data.tar.gz: 576a86cba0236c0536c20a653056fcb4b5e27e9c2c13216a4be1f7b471b7b640ed56d7527783213803bc17481877a5f3a2cffa8ce23846ab43ed4253d23b98dc
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Zeridion
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# zeridion-flare (Ruby)
|
|
2
|
+
|
|
3
|
+
Ruby SDK for the [Zeridion Flare](https://docs.zeridion.com/flare) managed background-jobs API.
|
|
4
|
+
|
|
5
|
+
[](https://rubygems.org/gems/zeridion-flare)
|
|
6
|
+
[](https://opensource.org/licenses/MIT)
|
|
7
|
+
|
|
8
|
+
**[Full documentation at docs.zeridion.com/flare](https://docs.zeridion.com/flare)**
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
gem install zeridion-flare
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Or add to your `Gemfile`:
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
gem "zeridion-flare", "~> 0.2"
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Requires Ruby 3.2+. **Zero** runtime dependencies — uses stdlib `Net::HTTP`, `OpenSSL`, and `JSON` only. The opt-in worker runtime (`require "zeridion_flare/worker"`) ships in the same gem and adds no dependencies.
|
|
23
|
+
|
|
24
|
+
## Quick start
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
require "zeridion_flare"
|
|
28
|
+
|
|
29
|
+
client = Zeridion::Flare::Client.new(api_key: "zf_live_sk_...")
|
|
30
|
+
# Or, with FLARE_API_KEY set in the environment:
|
|
31
|
+
# client = Zeridion::Flare::Client.new
|
|
32
|
+
|
|
33
|
+
job = client.create_job(
|
|
34
|
+
"job_type" => "SendWelcomeEmail",
|
|
35
|
+
"payload" => { "email" => "alice@example.com" },
|
|
36
|
+
"queue" => "default",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
puts job["id"], job["state"] # "job_abc123", "pending"
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## `Client.new(...)`
|
|
43
|
+
|
|
44
|
+
| Keyword | Default | Notes |
|
|
45
|
+
|------------------------|------------------------------|--------------------------------------------------------|
|
|
46
|
+
| `api_key:` | `ENV["FLARE_API_KEY"]` | Required — pass directly or via env var |
|
|
47
|
+
| `base_url:` | `https://api.zeridion.com` | Override for dev / staging environments |
|
|
48
|
+
| `max_retries:` | `3` | Set `0` to disable retries |
|
|
49
|
+
| `retry_base_delay_ms:` | `500` | Base for exponential schedule |
|
|
50
|
+
| `retry_max_delay_ms:` | `30_000` | Cap on a single backoff wait (and on `Retry-After`) |
|
|
51
|
+
| `timeout_seconds:` | `30` | Per-request read timeout |
|
|
52
|
+
| `transport:` | `NetHttpTransport.new` | Swap for tests or to plug in a logging proxy |
|
|
53
|
+
|
|
54
|
+
Every public method accepts optional `idempotency_key:` and `request_id:` keyword arguments, sent as the `Idempotency-Key` and `X-Request-Id` headers.
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
client.create_job(body, idempotency_key: "optional", request_id: "optional")
|
|
58
|
+
client.get_job(id) # nil if 404
|
|
59
|
+
client.list_jobs(state: "failed", limit: 25)
|
|
60
|
+
client.cancel_job(id) # nil if 409
|
|
61
|
+
client.retry_job(id) # nil if 409
|
|
62
|
+
client.register_worker(body) # raises on non-2xx
|
|
63
|
+
client.poll_workers(body)
|
|
64
|
+
client.heartbeat(body) # {"status" => "ok" | "cancel"}
|
|
65
|
+
client.ack_worker(body)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Automatic retries
|
|
69
|
+
|
|
70
|
+
The SDK auto-retries HTTP `429` / `502` / `503` / `504` responses and transient network errors with full-jitter exponential backoff capped at `retry_max_delay_ms`. The `Retry-After` header is honored when present.
|
|
71
|
+
|
|
72
|
+
See the [stable error-code registry](https://docs.zeridion.com/flare/api/errors) for every `error.code` string the API can return.
|
|
73
|
+
|
|
74
|
+
## Error handling
|
|
75
|
+
|
|
76
|
+
All API errors inherit from `Zeridion::Flare::FlareError`:
|
|
77
|
+
|
|
78
|
+
```ruby
|
|
79
|
+
begin
|
|
80
|
+
client.create_job("job_type" => "MyJob")
|
|
81
|
+
rescue Zeridion::Flare::RateLimitError => e
|
|
82
|
+
sleep(e.retry_after.to_i) # honor X-RateLimit-Reset
|
|
83
|
+
rescue Zeridion::Flare::AuthError
|
|
84
|
+
# 401 — invalid API key
|
|
85
|
+
rescue Zeridion::Flare::ConflictError => e
|
|
86
|
+
case e.code
|
|
87
|
+
when "idempotency_key_reuse"
|
|
88
|
+
# Same Idempotency-Key reused with a different body
|
|
89
|
+
end
|
|
90
|
+
rescue Zeridion::Flare::FlareError => e
|
|
91
|
+
warn "Error #{e.status_code}: #{e.code} — #{e.message} (req #{e.request_id})"
|
|
92
|
+
end
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Each error exposes `#status_code`, `#code`, `#request_id`, and `#message`. `RateLimitError` additionally exposes `#limit`, `#remaining`, `#retry_after`.
|
|
96
|
+
|
|
97
|
+
## Verifying webhook signatures
|
|
98
|
+
|
|
99
|
+
If you've configured outbound webhooks via the `/flare/v1/webhooks` API, verify the `X-Zeridion-Signature` header on each incoming delivery:
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
require "zeridion_flare"
|
|
103
|
+
|
|
104
|
+
post "/hooks/zeridion" do
|
|
105
|
+
payload = request.body.read
|
|
106
|
+
header = request.env["HTTP_X_ZERIDION_SIGNATURE"].to_s
|
|
107
|
+
|
|
108
|
+
unless Zeridion::Flare::Webhook.verify(payload, header, ENV["ZERIDION_WEBHOOK_SECRET"], tolerance_seconds: 300)
|
|
109
|
+
halt 400, "invalid signature"
|
|
110
|
+
end
|
|
111
|
+
# ... process the event ...
|
|
112
|
+
200
|
|
113
|
+
end
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`Webhook.verify` is HMAC-SHA256 over `<unix_timestamp>.<raw_body>`, constant-time-compared (via `OpenSSL.secure_compare`) against every `v1=` value in the header (supports secret rotation). The optional `tolerance_seconds:` parameter rejects replays older than that many seconds.
|
|
117
|
+
|
|
118
|
+
## Worker runtime
|
|
119
|
+
|
|
120
|
+
Beyond the thin client, the gem ships an opt-in **background-worker runtime**.
|
|
121
|
+
Define job classes and let it register, long-poll, dispatch, heartbeat with
|
|
122
|
+
progress, honor server cancellation, and drain gracefully on shutdown — instead
|
|
123
|
+
of hand-writing the poll/ack loop.
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
require "zeridion_flare/worker"
|
|
127
|
+
|
|
128
|
+
class SendWelcomeEmail
|
|
129
|
+
include Zeridion::Flare::Job
|
|
130
|
+
flare_options queue: "email", max_attempts: 5, timeout: 120
|
|
131
|
+
|
|
132
|
+
def perform(payload, ctx)
|
|
133
|
+
return if ctx.cancelled?
|
|
134
|
+
return if AlreadySent.exists?(ctx.job_id) # at-least-once → idempotent
|
|
135
|
+
Mailer.welcome(payload["email"]).deliver_now
|
|
136
|
+
ctx.report_progress(1.0)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Recurring (cron) jobs are payloadless and run on a schedule:
|
|
141
|
+
class NightlyCleanup
|
|
142
|
+
include Zeridion::Flare::RecurringJob
|
|
143
|
+
flare_options cron: "0 3 * * *", queue: "maintenance", timezone: "UTC"
|
|
144
|
+
|
|
145
|
+
def perform(ctx)
|
|
146
|
+
PurgeExpired.run
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
worker = Zeridion::Flare::Worker::Worker.new(concurrency: 5)
|
|
151
|
+
worker.run # blocks; SIGTERM/SIGINT → graceful drain
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Including the `Job` / `RecurringJob` mixin registers the class automatically.
|
|
155
|
+
The worker reads the same `FLARE_API_KEY` fallback and base-URL default as the
|
|
156
|
+
thin client. Handlers run **at least once** — dedupe on `ctx.job_id` to stay
|
|
157
|
+
idempotent.
|
|
158
|
+
|
|
159
|
+
Full reference: [docs.zeridion.com/flare/sdks/ruby/worker](https://docs.zeridion.com/flare/sdks/ruby/worker).
|
|
160
|
+
|
|
161
|
+
## Sample app
|
|
162
|
+
|
|
163
|
+
A runnable worker starter (one payload job + one recurring job + graceful drain,
|
|
164
|
+
plus an `enqueue.rb` feeder) lives at
|
|
165
|
+
[`samples/ruby-starter/`](../../samples/ruby-starter/).
|
|
166
|
+
|
|
167
|
+
## Links
|
|
168
|
+
|
|
169
|
+
- Documentation: https://docs.zeridion.com/flare
|
|
170
|
+
- Dashboard: https://dashboard.zeridion.com/
|
|
171
|
+
- .NET SDK: ../csharp/README.md
|
|
172
|
+
- TypeScript SDK: ../typescript/README.md
|
|
173
|
+
- Python SDK: ../python/README.md
|
|
174
|
+
- Go SDK: ../go/README.md
|
|
175
|
+
- PHP SDK: ../php/README.md
|
|
@@ -0,0 +1,298 @@
|
|
|
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 "version"
|
|
10
|
+
|
|
11
|
+
module Zeridion
|
|
12
|
+
module Flare
|
|
13
|
+
# Default HTTP transport backed by Ruby's stdlib Net::HTTP. Tests can swap
|
|
14
|
+
# in a custom object that responds to #send_request with the same
|
|
15
|
+
# signature to capture requests / queue responses without touching the
|
|
16
|
+
# network.
|
|
17
|
+
class NetHttpTransport
|
|
18
|
+
# @return [Hash{Symbol=>Object}] { status:, headers:, body: }
|
|
19
|
+
def send_request(method:, url:, headers:, body:, timeout:)
|
|
20
|
+
uri = URI.parse(url)
|
|
21
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
22
|
+
http.use_ssl = (uri.scheme == "https")
|
|
23
|
+
http.open_timeout = 10
|
|
24
|
+
http.read_timeout = timeout
|
|
25
|
+
|
|
26
|
+
req = build_request(method, uri, headers, body)
|
|
27
|
+
resp = http.request(req)
|
|
28
|
+
|
|
29
|
+
parsed_headers = {}
|
|
30
|
+
resp.each_header { |k, v| parsed_headers[k.downcase] = v }
|
|
31
|
+
{ status: resp.code.to_i, headers: parsed_headers, body: resp.body.to_s }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def build_request(method, uri, headers, body)
|
|
37
|
+
path = uri.request_uri
|
|
38
|
+
req_class = {
|
|
39
|
+
"GET" => Net::HTTP::Get,
|
|
40
|
+
"POST" => Net::HTTP::Post,
|
|
41
|
+
"PUT" => Net::HTTP::Put,
|
|
42
|
+
"PATCH" => Net::HTTP::Patch,
|
|
43
|
+
"DELETE" => Net::HTTP::Delete,
|
|
44
|
+
}.fetch(method.upcase) do
|
|
45
|
+
raise ArgumentError, "unsupported HTTP method: #{method}"
|
|
46
|
+
end
|
|
47
|
+
req = req_class.new(path)
|
|
48
|
+
headers.each { |k, v| req[k] = v }
|
|
49
|
+
req.body = body if body
|
|
50
|
+
req
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Synchronous Flare API client.
|
|
55
|
+
class Client
|
|
56
|
+
DEFAULT_BASE_URL = "https://api.zeridion.com"
|
|
57
|
+
API_PREFIX = "/flare/v1"
|
|
58
|
+
RETRYABLE_STATUSES = [429, 502, 503, 504].freeze
|
|
59
|
+
|
|
60
|
+
attr_reader :api_key, :base_url
|
|
61
|
+
|
|
62
|
+
# @param api_key [String, nil] API key. Falls back to FLARE_API_KEY env var.
|
|
63
|
+
# @param base_url [String] Base URL — default https://api.zeridion.com
|
|
64
|
+
# @param max_retries [Integer] Set 0 to disable retries.
|
|
65
|
+
# @param retry_base_delay_ms [Integer] Base for exponential schedule.
|
|
66
|
+
# @param retry_max_delay_ms [Integer] Cap on a single backoff wait.
|
|
67
|
+
# @param timeout_seconds [Numeric] Per-request read timeout.
|
|
68
|
+
# @param transport [Object] Object responding to #send_request; default NetHttpTransport.
|
|
69
|
+
# @param max_response_bytes [Integer] Hard cap on response body bytes
|
|
70
|
+
# the client will read. Protects against a misbehaving or malicious
|
|
71
|
+
# server sending an unbounded body that would otherwise blow Ruby
|
|
72
|
+
# memory. Defaults to 10 MiB; set 0 to disable.
|
|
73
|
+
def initialize(
|
|
74
|
+
api_key: nil,
|
|
75
|
+
base_url: DEFAULT_BASE_URL,
|
|
76
|
+
max_retries: 3,
|
|
77
|
+
retry_base_delay_ms: 500,
|
|
78
|
+
retry_max_delay_ms: 30_000,
|
|
79
|
+
timeout_seconds: 30,
|
|
80
|
+
max_response_bytes: 10 * 1024 * 1024,
|
|
81
|
+
transport: nil
|
|
82
|
+
)
|
|
83
|
+
api_key ||= ENV["FLARE_API_KEY"]
|
|
84
|
+
if api_key.nil? || api_key.to_s.empty?
|
|
85
|
+
raise ArgumentError,
|
|
86
|
+
"Client: api_key is required (pass directly or set the FLARE_API_KEY environment variable)"
|
|
87
|
+
end
|
|
88
|
+
@api_key = api_key
|
|
89
|
+
@base_url = base_url.chomp("/")
|
|
90
|
+
@max_retries = max_retries
|
|
91
|
+
@retry_base_delay_ms = retry_base_delay_ms
|
|
92
|
+
@retry_max_delay_ms = retry_max_delay_ms
|
|
93
|
+
@timeout_seconds = timeout_seconds
|
|
94
|
+
@max_response_bytes = max_response_bytes
|
|
95
|
+
@transport = transport || NetHttpTransport.new
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# ── Jobs ────────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
def create_job(body, idempotency_key: nil, request_id: nil)
|
|
101
|
+
resp = request("POST", "/jobs", body: body, idempotency_key: idempotency_key, request_id: request_id)
|
|
102
|
+
decode(maybe_raise(resp))
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def get_job(job_id, idempotency_key: nil, request_id: nil)
|
|
106
|
+
resp = request("GET", "/jobs/#{escape_path(job_id)}", idempotency_key: idempotency_key, request_id: request_id)
|
|
107
|
+
return nil if resp[:status] == 404
|
|
108
|
+
|
|
109
|
+
decode(maybe_raise(resp))
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def list_jobs(
|
|
113
|
+
state: nil, queue: nil, job_type: nil, created_after: nil,
|
|
114
|
+
created_before: nil, limit: nil, cursor: nil,
|
|
115
|
+
idempotency_key: nil, request_id: nil
|
|
116
|
+
)
|
|
117
|
+
params = {
|
|
118
|
+
"state" => state, "queue" => queue, "job_type" => job_type,
|
|
119
|
+
"created_after" => created_after, "created_before" => created_before,
|
|
120
|
+
"limit" => limit, "cursor" => cursor,
|
|
121
|
+
}.reject { |_, v| v.nil? }
|
|
122
|
+
path = "/jobs"
|
|
123
|
+
path += "?#{URI.encode_www_form(params)}" unless params.empty?
|
|
124
|
+
resp = request("GET", path, idempotency_key: idempotency_key, request_id: request_id)
|
|
125
|
+
decode(maybe_raise(resp))
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def cancel_job(job_id, idempotency_key: nil, request_id: nil)
|
|
129
|
+
resp = request("POST", "/jobs/#{escape_path(job_id)}/cancel",
|
|
130
|
+
idempotency_key: idempotency_key, request_id: request_id)
|
|
131
|
+
return nil if resp[:status] == 409
|
|
132
|
+
|
|
133
|
+
decode(maybe_raise(resp))
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def retry_job(job_id, idempotency_key: nil, request_id: nil)
|
|
137
|
+
resp = request("POST", "/jobs/#{escape_path(job_id)}/retry",
|
|
138
|
+
idempotency_key: idempotency_key, request_id: request_id)
|
|
139
|
+
return nil if resp[:status] == 409
|
|
140
|
+
|
|
141
|
+
decode(maybe_raise(resp))
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# ── Workers ─────────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
# Announce this worker's identity, served queues + job types, and any
|
|
147
|
+
# recurring schedules. Best-effort on the server; STRICT here (raises on
|
|
148
|
+
# non-2xx) — the worker runtime is responsible for swallowing failures.
|
|
149
|
+
def register_worker(body, idempotency_key: nil, request_id: nil)
|
|
150
|
+
resp = request("POST", "/workers/register", body: body, idempotency_key: idempotency_key,
|
|
151
|
+
request_id: request_id)
|
|
152
|
+
decode(maybe_raise(resp))
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def poll_workers(body, idempotency_key: nil, request_id: nil)
|
|
156
|
+
resp = request("POST", "/workers/poll", body: body, idempotency_key: idempotency_key, request_id: request_id)
|
|
157
|
+
decode(maybe_raise(resp))
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Send a single liveness/progress beat for one in-flight job. STRICT here
|
|
161
|
+
# (raises on non-2xx); the worker runtime swallows. Returns the decoded
|
|
162
|
+
# response Hash, e.g. {"status" => "ok"} or {"status" => "cancel"}.
|
|
163
|
+
def heartbeat(body, idempotency_key: nil, request_id: nil)
|
|
164
|
+
resp = request("POST", "/workers/heartbeat", body: body, idempotency_key: idempotency_key,
|
|
165
|
+
request_id: request_id)
|
|
166
|
+
decode(maybe_raise(resp))
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def ack_worker(body, idempotency_key: nil, request_id: nil)
|
|
170
|
+
resp = request("POST", "/workers/ack", body: body, idempotency_key: idempotency_key, request_id: request_id)
|
|
171
|
+
decode(maybe_raise(resp))
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# ── Internal ────────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
private
|
|
177
|
+
|
|
178
|
+
def escape_path(segment)
|
|
179
|
+
URI.encode_www_form_component(segment)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def request(method, path, body: nil, idempotency_key: nil, request_id: nil)
|
|
183
|
+
url = "#{@base_url}#{API_PREFIX}#{path}"
|
|
184
|
+
headers = {
|
|
185
|
+
"Authorization" => "Bearer #{@api_key}",
|
|
186
|
+
"Accept" => "application/json",
|
|
187
|
+
"User-Agent" => "zeridion-flare-ruby/#{Zeridion::Flare::VERSION}",
|
|
188
|
+
}
|
|
189
|
+
body_str = nil
|
|
190
|
+
if body
|
|
191
|
+
body_str = JSON.generate(body)
|
|
192
|
+
headers["Content-Type"] = "application/json"
|
|
193
|
+
end
|
|
194
|
+
headers["Idempotency-Key"] = idempotency_key if idempotency_key
|
|
195
|
+
headers["X-Request-Id"] = request_id if request_id
|
|
196
|
+
|
|
197
|
+
last_exception = nil
|
|
198
|
+
attempt = 0
|
|
199
|
+
loop do
|
|
200
|
+
begin
|
|
201
|
+
resp = @transport.send_request(method: method, url: url, headers: headers, body: body_str,
|
|
202
|
+
timeout: @timeout_seconds)
|
|
203
|
+
rescue StandardError => e
|
|
204
|
+
raise e if attempt == @max_retries
|
|
205
|
+
|
|
206
|
+
last_exception = e
|
|
207
|
+
sleep(compute_backoff_seconds(attempt, nil))
|
|
208
|
+
attempt += 1
|
|
209
|
+
next
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
return resp unless RETRYABLE_STATUSES.include?(resp[:status]) && attempt < @max_retries
|
|
213
|
+
|
|
214
|
+
sleep(compute_backoff_seconds(attempt, resp[:headers]["retry-after"]))
|
|
215
|
+
attempt += 1
|
|
216
|
+
end
|
|
217
|
+
rescue StandardError
|
|
218
|
+
raise last_exception if last_exception
|
|
219
|
+
|
|
220
|
+
raise
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def compute_backoff_seconds(attempt, retry_after_raw)
|
|
224
|
+
if retry_after_raw&.match?(/\A\d+\z/)
|
|
225
|
+
seconds = retry_after_raw.to_i
|
|
226
|
+
return [seconds, @retry_max_delay_ms / 1000.0].min if seconds >= 0
|
|
227
|
+
end
|
|
228
|
+
ceiling_ms = [@retry_base_delay_ms * (2**attempt), @retry_max_delay_ms].min
|
|
229
|
+
rand(0..ceiling_ms) / 1000.0
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def maybe_raise(resp)
|
|
233
|
+
status = resp[:status]
|
|
234
|
+
return resp if status >= 200 && status < 300
|
|
235
|
+
|
|
236
|
+
code = "unknown"
|
|
237
|
+
message = "HTTP #{status}"
|
|
238
|
+
request_id = ""
|
|
239
|
+
if !resp[:body].empty?
|
|
240
|
+
begin
|
|
241
|
+
envelope = JSON.parse(resp[:body])
|
|
242
|
+
err = envelope.is_a?(Hash) ? envelope["error"] : nil
|
|
243
|
+
if err.is_a?(Hash)
|
|
244
|
+
code = err["code"] || code
|
|
245
|
+
message = err["message"] || message
|
|
246
|
+
request_id = err["request_id"] || request_id
|
|
247
|
+
end
|
|
248
|
+
rescue JSON::ParserError
|
|
249
|
+
# Non-JSON error body (e.g. plain-text 502 from a reverse proxy)
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
case status
|
|
254
|
+
when 401
|
|
255
|
+
raise AuthError.new(message, status_code: status, code: code, request_id: request_id)
|
|
256
|
+
when 402
|
|
257
|
+
raise QuotaError.new(message, status_code: status, code: code, request_id: request_id)
|
|
258
|
+
when 404
|
|
259
|
+
raise NotFoundError.new(message, status_code: status, code: code, request_id: request_id)
|
|
260
|
+
when 409
|
|
261
|
+
raise ConflictError.new(message, status_code: status, code: code, request_id: request_id)
|
|
262
|
+
when 429
|
|
263
|
+
limit = (resp[:headers]["x-ratelimit-limit"] || "0").to_i
|
|
264
|
+
remaining = (resp[:headers]["x-ratelimit-remaining"] || "0").to_i
|
|
265
|
+
reset_raw = resp[:headers]["x-ratelimit-reset"]
|
|
266
|
+
retry_after = reset_raw&.match?(/\A\d+\z/) ? reset_raw.to_i : nil
|
|
267
|
+
raise RateLimitError.new(message, status_code: status, code: code, request_id: request_id,
|
|
268
|
+
limit: limit, remaining: remaining, retry_after: retry_after)
|
|
269
|
+
else
|
|
270
|
+
raise FlareError.new(message, status_code: status, code: code, request_id: request_id)
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def decode(resp)
|
|
275
|
+
return {} if resp[:body].empty?
|
|
276
|
+
|
|
277
|
+
# Cap the response body the parser sees so a misbehaving or malicious
|
|
278
|
+
# server can't blow Ruby memory with a multi-GiB payload. When
|
|
279
|
+
# @max_response_bytes is 0 the cap is disabled.
|
|
280
|
+
if @max_response_bytes.positive? && resp[:body].bytesize > @max_response_bytes
|
|
281
|
+
# FlareError#initialize requires status_code:/code:/request_id: keywords,
|
|
282
|
+
# so the bare `raise FlareError, msg` form would actually raise
|
|
283
|
+
# ArgumentError: missing keywords. Construct it explicitly with
|
|
284
|
+
# placeholders — the body-cap path has no upstream API call to
|
|
285
|
+
# attribute, so status_code is 0 and request_id is nil.
|
|
286
|
+
raise FlareError.new(
|
|
287
|
+
"flare: response body #{resp[:body].bytesize} bytes " \
|
|
288
|
+
"exceeds max_response_bytes=#{@max_response_bytes}",
|
|
289
|
+
status_code: 0,
|
|
290
|
+
code: "body_too_large",
|
|
291
|
+
request_id: nil,
|
|
292
|
+
)
|
|
293
|
+
end
|
|
294
|
+
JSON.parse(resp[:body])
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Zeridion
|
|
4
|
+
module Flare
|
|
5
|
+
# Base exception for all Flare API errors. Every other exception in this
|
|
6
|
+
# SDK inherits from FlareError, so a single `rescue FlareError` covers them.
|
|
7
|
+
class FlareError < StandardError
|
|
8
|
+
attr_reader :status_code, :code, :request_id
|
|
9
|
+
|
|
10
|
+
def initialize(message, status_code:, code:, request_id:)
|
|
11
|
+
super(message)
|
|
12
|
+
@status_code = status_code
|
|
13
|
+
@code = code
|
|
14
|
+
@request_id = request_id
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
class AuthError < FlareError; end
|
|
19
|
+
class QuotaError < FlareError; end
|
|
20
|
+
class NotFoundError < FlareError; end
|
|
21
|
+
class ConflictError < FlareError; end
|
|
22
|
+
|
|
23
|
+
class RateLimitError < FlareError
|
|
24
|
+
attr_reader :limit, :remaining, :retry_after
|
|
25
|
+
|
|
26
|
+
def initialize(message, status_code:, code:, request_id:, limit:, remaining:, retry_after:)
|
|
27
|
+
super(message, status_code: status_code, code: code, request_id: request_id)
|
|
28
|
+
@limit = limit
|
|
29
|
+
@remaining = remaining
|
|
30
|
+
@retry_after = retry_after
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
|
|
5
|
+
module Zeridion
|
|
6
|
+
module Flare
|
|
7
|
+
# Verify outbound webhook signatures from Zeridion Flare.
|
|
8
|
+
#
|
|
9
|
+
# Each delivery is signed with HMAC-SHA256 over `<unix_timestamp>.<raw_body>`
|
|
10
|
+
# using the subscription secret. The signature is sent in the
|
|
11
|
+
# X-Zeridion-Signature header in the form `t=<unix_timestamp>,v1=<hex>`.
|
|
12
|
+
module Webhook
|
|
13
|
+
# @param payload [String] Raw request body. MUST be the exact bytes the
|
|
14
|
+
# server signed — do not re-encode parsed JSON.
|
|
15
|
+
# @param signature_header [String] Full value of the X-Zeridion-Signature header.
|
|
16
|
+
# @param secret [String] The subscription secret returned at creation.
|
|
17
|
+
# @param tolerance_seconds [Integer, nil] If set, reject signatures whose timestamp
|
|
18
|
+
# differs from the current time by more
|
|
19
|
+
# than this many seconds (replay protection;
|
|
20
|
+
# recommended: 300).
|
|
21
|
+
# @return [Boolean] true iff the signature is valid (and within tolerance, if set).
|
|
22
|
+
def self.verify(payload, signature_header, secret, tolerance_seconds: nil)
|
|
23
|
+
return false if signature_header.nil? || signature_header.empty?
|
|
24
|
+
return false if secret.nil? || secret.empty?
|
|
25
|
+
|
|
26
|
+
timestamp = nil
|
|
27
|
+
signatures = []
|
|
28
|
+
signature_header.split(",").each do |part|
|
|
29
|
+
trimmed = part.strip
|
|
30
|
+
eq = trimmed.index("=")
|
|
31
|
+
next if eq.nil? || eq.zero?
|
|
32
|
+
|
|
33
|
+
key = trimmed[0, eq]
|
|
34
|
+
value = trimmed[(eq + 1)..]
|
|
35
|
+
case key
|
|
36
|
+
when "t"
|
|
37
|
+
timestamp = Integer(value, 10) if value.match?(/\A\d+\z/)
|
|
38
|
+
when "v1"
|
|
39
|
+
signatures << value.downcase
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
return false if timestamp.nil? || signatures.empty?
|
|
44
|
+
|
|
45
|
+
if tolerance_seconds
|
|
46
|
+
now = Time.now.to_i
|
|
47
|
+
return false if (now - timestamp).abs > tolerance_seconds
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
signing_input = "#{timestamp}.#{payload}"
|
|
51
|
+
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, signing_input)
|
|
52
|
+
|
|
53
|
+
signatures.any? { |provided| secure_compare(expected, provided) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Constant-time string comparison (defends against timing-attack
|
|
57
|
+
# signature-guessing). OpenSSL.secure_compare is available in Ruby 3.0+;
|
|
58
|
+
# we fall back to a hand-rolled loop for older runtimes if it's missing.
|
|
59
|
+
def self.secure_compare(a, b)
|
|
60
|
+
return false if a.bytesize != b.bytesize
|
|
61
|
+
|
|
62
|
+
if OpenSSL.respond_to?(:secure_compare)
|
|
63
|
+
OpenSSL.secure_compare(a, b)
|
|
64
|
+
else
|
|
65
|
+
l = a.unpack("C*")
|
|
66
|
+
r = 0
|
|
67
|
+
i = -1
|
|
68
|
+
b.each_byte { |v| r |= v ^ l[i += 1] }
|
|
69
|
+
r.zero?
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|