jobcelis 1.3.0 → 1.4.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 +4 -4
- data/README.md +17 -1
- data/lib/jobcelis/client.rb +124 -48
- data/lib/jobcelis.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6063822db94053ab346b13e3bd90b843f62bb0cff1c86554b8999c147d29d5cb
|
|
4
|
+
data.tar.gz: 3c873ce53d2304093e6c1632aa439d72765c1373a2d154a48b102733c4580b93
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4935d8e1b10c18224ffb04a5d9908455720ed6e27a6093d4428b2116d153cb9814a6765cc0464abfdb8f791fdbe92f6562918f5be3f53da1d2bd735514b1e696
|
|
7
|
+
data.tar.gz: 85b68964a08b4b91df15f684eba19e5ea694e35ef317188358dfead5bc59ba7458a142cafa303c373f89d28f0ae27b6d15e1a775f1070a444bfc822dacc5d4e7
|
data/README.md
CHANGED
|
@@ -349,6 +349,22 @@ rescue Jobcelis::Error => e
|
|
|
349
349
|
end
|
|
350
350
|
```
|
|
351
351
|
|
|
352
|
+
## Retries
|
|
353
|
+
|
|
354
|
+
The client retries a request automatically when the connection fails (refused or reset connection, DNS or TLS failure, timeout) or the API answers 429, 502, 503 or 504, for example while Jobcelis is being deployed. It waits 1 s, 2 s, 4 s, 8 s, 16 s and 30 s between attempts (with ±20 % jitter), or what the `Retry-After` header asks for, up to 60 s. By default it makes up to 6 retries, about 60 s in total. Other errors, such as 400 or 500, are raised immediately as `Jobcelis::Error`; after the last retry you get the same error you would get without retries.
|
|
355
|
+
|
|
356
|
+
GET, PUT, PATCH and DELETE are retried in all of these cases. POST is retried on 429/502/503/504, and after a connection failure only when it carries an idempotency key, so a request can never be applied twice:
|
|
357
|
+
|
|
358
|
+
- `send_event` sends an `X-Idempotency-Key` header, the same on every retry. Pass `idempotency_key:` to use your own key; otherwise a UUID is generated.
|
|
359
|
+
- `send_events` adds an `idempotency_key` to every event that does not have one.
|
|
360
|
+
|
|
361
|
+
Change or disable retries when you create the client:
|
|
362
|
+
|
|
363
|
+
```ruby
|
|
364
|
+
client = Jobcelis::Client.new(api_key: "your_api_key", max_retries: 3)
|
|
365
|
+
client = Jobcelis::Client.new(api_key: "your_api_key", max_retries: 0) # no retries
|
|
366
|
+
```
|
|
367
|
+
|
|
352
368
|
## Webhook Signature Verification
|
|
353
369
|
|
|
354
370
|
```ruby
|
|
@@ -380,4 +396,4 @@ BSL-1.1 (Business Source License)
|
|
|
380
396
|
|
|
381
397
|
## Releasing
|
|
382
398
|
|
|
383
|
-
|
|
399
|
+
Merge to `main`: the [Release workflow](.github/workflows/release.yml) publishes every change to the package as the next patch version (1.3.1 → 1.3.2) to RubyGems, writes the version into `jobcelis.gemspec` and `lib/jobcelis.rb` and tags the commit. Changes to docs, tests or CI publish nothing. For a minor or major version, raise it in `jobcelis.gemspec` and `lib/jobcelis.rb` in the same pull request. Every pull request runs [CI](.github/workflows/ci.yml), which runs the tests (`ruby -Ilib -Itest -e 'Dir["test/**/*_test.rb"].each { |f| require File.expand_path(f) }'`).
|
data/lib/jobcelis/client.rb
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
require "net/http"
|
|
4
4
|
require "json"
|
|
5
5
|
require "uri"
|
|
6
|
+
require "time"
|
|
7
|
+
require "openssl"
|
|
8
|
+
require "securerandom"
|
|
6
9
|
|
|
7
10
|
module Jobcelis
|
|
8
11
|
# Client for the Jobcelis Event Infrastructure Platform API.
|
|
@@ -12,13 +15,32 @@ module Jobcelis
|
|
|
12
15
|
# client = Jobcelis::Client.new(api_key: "your_api_key")
|
|
13
16
|
#
|
|
14
17
|
class Client
|
|
15
|
-
#
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
18
|
+
# Responses that mean the request was not processed and can be sent again.
|
|
19
|
+
RETRYABLE_STATUSES = [429, 502, 503, 504].freeze
|
|
20
|
+
|
|
21
|
+
# Connection, DNS, TLS and timeout failures.
|
|
22
|
+
NETWORK_ERRORS = [
|
|
23
|
+
SocketError, IOError, EOFError, Timeout::Error, OpenSSL::SSL::SSLError,
|
|
24
|
+
Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::ECONNABORTED, Errno::EHOSTUNREACH,
|
|
25
|
+
Errno::ENETUNREACH, Errno::ETIMEDOUT, Errno::EPIPE
|
|
26
|
+
].freeze
|
|
27
|
+
|
|
28
|
+
MAX_BACKOFF = 30.0
|
|
29
|
+
MAX_RETRY_AFTER = 60.0
|
|
30
|
+
|
|
31
|
+
# @param api_key [String] Your project API key.
|
|
32
|
+
# @param base_url [String] Base URL of the Jobcelis API (default: https://jobcelis.com).
|
|
33
|
+
# @param timeout [Integer] Request timeout in seconds (default: 30).
|
|
34
|
+
# @param max_retries [Integer] Retries for network errors and 429/502/503/504 responses
|
|
35
|
+
# (default: 6, about 60 s in total). 0 disables retries.
|
|
36
|
+
# @param retry_base_delay [Float] Delay in seconds before the first retry, doubled on each
|
|
37
|
+
# following one up to 30 s (default: 1.0).
|
|
38
|
+
def initialize(api_key:, base_url: "https://jobcelis.com", timeout: 30, max_retries: 6, retry_base_delay: 1.0)
|
|
19
39
|
@api_key = api_key
|
|
20
40
|
@base_url = base_url.chomp("/")
|
|
21
41
|
@timeout = timeout
|
|
42
|
+
@max_retries = [Integer(max_retries), 0].max
|
|
43
|
+
@retry_base_delay = Float(retry_base_delay)
|
|
22
44
|
@auth_token = nil
|
|
23
45
|
end
|
|
24
46
|
|
|
@@ -58,13 +80,19 @@ module Jobcelis
|
|
|
58
80
|
# ------------------------------------------------------------------
|
|
59
81
|
|
|
60
82
|
# Send a single event.
|
|
83
|
+
#
|
|
84
|
+
# The request carries an X-Idempotency-Key header so it can be retried
|
|
85
|
+
# safely: pass +idempotency_key:+ to choose it, otherwise a UUID is generated.
|
|
61
86
|
def send_event(topic, payload, **kwargs)
|
|
62
|
-
|
|
87
|
+
key = kwargs[:idempotency_key] || kwargs["idempotency_key"] || new_idempotency_key
|
|
88
|
+
request("POST", "/api/v1/events", body: { topic: topic, payload: payload, **kwargs }, idempotency_key: key.to_s)
|
|
63
89
|
end
|
|
64
90
|
|
|
65
91
|
# Send up to 1000 events in a batch.
|
|
92
|
+
#
|
|
93
|
+
# Events without an +idempotency_key+ get a generated one so the batch can be retried safely.
|
|
66
94
|
def send_events(events)
|
|
67
|
-
post("/api/v1/events/batch", { events: events })
|
|
95
|
+
post("/api/v1/events/batch", { events: with_idempotency_keys(events) })
|
|
68
96
|
end
|
|
69
97
|
|
|
70
98
|
# Get event details.
|
|
@@ -652,53 +680,95 @@ module Jobcelis
|
|
|
652
680
|
end
|
|
653
681
|
|
|
654
682
|
def public_post(path, body)
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
req = Net::HTTP::Post.new(uri)
|
|
659
|
-
req["Content-Type"] = "application/json"
|
|
660
|
-
req.body = JSON.generate(body)
|
|
683
|
+
resp = send_with_retries("POST", path, body: body, auth: false)
|
|
684
|
+
handle_response(resp)
|
|
685
|
+
end
|
|
661
686
|
|
|
662
|
-
|
|
687
|
+
def request(method, path, params: nil, body: nil, idempotency_key: nil)
|
|
688
|
+
resp = send_with_retries(method, path, params: params, body: body, idempotency_key: idempotency_key)
|
|
663
689
|
handle_response(resp)
|
|
664
690
|
end
|
|
665
691
|
|
|
666
|
-
def
|
|
692
|
+
def request_raw(method, path, params: nil)
|
|
693
|
+
resp = send_with_retries(method, path, params: params)
|
|
694
|
+
raise_for_status(resp)
|
|
695
|
+
resp.body
|
|
696
|
+
end
|
|
697
|
+
|
|
698
|
+
# Sends the request, retrying network errors and 429/502/503/504 responses
|
|
699
|
+
# with exponential backoff. POST requests are only retried after a network
|
|
700
|
+
# error when they carry an idempotency key, so they can never be applied twice.
|
|
701
|
+
def send_with_retries(method, path, params: nil, body: nil, auth: true, idempotency_key: nil)
|
|
667
702
|
uri = build_uri(path, params)
|
|
668
|
-
|
|
703
|
+
payload = JSON.generate(body) if body
|
|
704
|
+
idempotent = method.upcase != "POST" || !idempotency_key.nil?
|
|
705
|
+
attempt = 0
|
|
669
706
|
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
707
|
+
loop do
|
|
708
|
+
begin
|
|
709
|
+
resp = perform_request(method, uri, payload, auth, idempotency_key)
|
|
710
|
+
rescue *NETWORK_ERRORS
|
|
711
|
+
raise if !idempotent || attempt >= @max_retries
|
|
675
712
|
|
|
676
|
-
|
|
677
|
-
|
|
713
|
+
attempt += 1
|
|
714
|
+
sleep(backoff_delay(attempt))
|
|
715
|
+
next
|
|
716
|
+
end
|
|
717
|
+
|
|
718
|
+
return resp unless RETRYABLE_STATUSES.include?(resp.code.to_i) && attempt < @max_retries
|
|
719
|
+
|
|
720
|
+
attempt += 1
|
|
721
|
+
sleep(retry_after(resp) || backoff_delay(attempt))
|
|
722
|
+
end
|
|
678
723
|
end
|
|
679
724
|
|
|
680
|
-
def
|
|
681
|
-
uri = build_uri(path, params)
|
|
725
|
+
def perform_request(method, uri, payload, auth, idempotency_key)
|
|
682
726
|
http = build_http(uri)
|
|
683
|
-
|
|
684
727
|
req = build_request(method, uri)
|
|
685
728
|
req["Content-Type"] = "application/json"
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
resp = http.request(req)
|
|
690
|
-
|
|
691
|
-
unless resp.is_a?(Net::HTTPSuccess)
|
|
692
|
-
detail = begin
|
|
693
|
-
parsed = JSON.parse(resp.body)
|
|
694
|
-
parsed.is_a?(Hash) ? (parsed["error"] || parsed) : parsed
|
|
695
|
-
rescue JSON::ParserError
|
|
696
|
-
resp.body
|
|
697
|
-
end
|
|
698
|
-
raise Jobcelis::Error.new(resp.code.to_i, detail)
|
|
729
|
+
if auth
|
|
730
|
+
req["X-Api-Key"] = @api_key
|
|
731
|
+
req["Authorization"] = "Bearer #{@auth_token}" if @auth_token
|
|
699
732
|
end
|
|
733
|
+
req["X-Idempotency-Key"] = idempotency_key if idempotency_key
|
|
734
|
+
req.body = payload if payload
|
|
735
|
+
http.request(req)
|
|
736
|
+
end
|
|
700
737
|
|
|
701
|
-
|
|
738
|
+
# Delay before retry number +attempt+ (1-based): base * 2^(attempt-1), capped, with +/-20% jitter.
|
|
739
|
+
def backoff_delay(attempt)
|
|
740
|
+
delay = [@retry_base_delay * (2**(attempt - 1)), MAX_BACKOFF].min
|
|
741
|
+
delay * (0.8 + (rand * 0.4))
|
|
742
|
+
end
|
|
743
|
+
|
|
744
|
+
# Seconds to wait from a Retry-After header (seconds or HTTP date), capped at 60 s.
|
|
745
|
+
def retry_after(resp)
|
|
746
|
+
value = resp["Retry-After"]
|
|
747
|
+
return nil if value.nil? || value.strip.empty?
|
|
748
|
+
|
|
749
|
+
seconds = if value.strip.match?(/\A\d+(\.\d+)?\z/)
|
|
750
|
+
value.to_f
|
|
751
|
+
else
|
|
752
|
+
Time.httpdate(value.strip) - Time.now
|
|
753
|
+
end
|
|
754
|
+
seconds.clamp(0, MAX_RETRY_AFTER)
|
|
755
|
+
rescue ArgumentError
|
|
756
|
+
nil
|
|
757
|
+
end
|
|
758
|
+
|
|
759
|
+
def new_idempotency_key
|
|
760
|
+
SecureRandom.uuid
|
|
761
|
+
end
|
|
762
|
+
|
|
763
|
+
# Gives every batch item without an idempotency_key its own key, without
|
|
764
|
+
# changing the caller's hashes, so a retried batch never duplicates events.
|
|
765
|
+
def with_idempotency_keys(events)
|
|
766
|
+
events.map do |event|
|
|
767
|
+
next event unless event.is_a?(Hash)
|
|
768
|
+
next event if event.key?(:idempotency_key) || event.key?("idempotency_key")
|
|
769
|
+
|
|
770
|
+
event.merge(idempotency_key: new_idempotency_key)
|
|
771
|
+
end
|
|
702
772
|
end
|
|
703
773
|
|
|
704
774
|
def build_uri(path, params = nil)
|
|
@@ -715,6 +785,8 @@ module Jobcelis
|
|
|
715
785
|
http.use_ssl = (uri.scheme == "https")
|
|
716
786
|
http.open_timeout = @timeout
|
|
717
787
|
http.read_timeout = @timeout
|
|
788
|
+
# Retries are handled by send_with_retries
|
|
789
|
+
http.max_retries = 0
|
|
718
790
|
http
|
|
719
791
|
end
|
|
720
792
|
|
|
@@ -731,19 +803,23 @@ module Jobcelis
|
|
|
731
803
|
end
|
|
732
804
|
|
|
733
805
|
def handle_response(resp)
|
|
734
|
-
|
|
735
|
-
detail = begin
|
|
736
|
-
parsed = JSON.parse(resp.body)
|
|
737
|
-
parsed.is_a?(Hash) ? (parsed["error"] || parsed) : parsed
|
|
738
|
-
rescue JSON::ParserError
|
|
739
|
-
resp.body
|
|
740
|
-
end
|
|
741
|
-
raise Jobcelis::Error.new(resp.code.to_i, detail)
|
|
742
|
-
end
|
|
806
|
+
raise_for_status(resp)
|
|
743
807
|
|
|
744
808
|
return nil if resp.code == "204"
|
|
745
809
|
|
|
746
810
|
JSON.parse(resp.body)
|
|
747
811
|
end
|
|
812
|
+
|
|
813
|
+
def raise_for_status(resp)
|
|
814
|
+
return if resp.is_a?(Net::HTTPSuccess)
|
|
815
|
+
|
|
816
|
+
detail = begin
|
|
817
|
+
parsed = JSON.parse(resp.body)
|
|
818
|
+
parsed.is_a?(Hash) ? (parsed["error"] || parsed) : parsed
|
|
819
|
+
rescue JSON::ParserError
|
|
820
|
+
resp.body
|
|
821
|
+
end
|
|
822
|
+
raise Jobcelis::Error.new(resp.code.to_i, detail)
|
|
823
|
+
end
|
|
748
824
|
end
|
|
749
825
|
end
|
data/lib/jobcelis.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: jobcelis
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jobcelis
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-11-08 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: net-http
|