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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 14ada6cadbf711ee7cc825f0aff8b70a50b78e1f8d02c89212fc62f0c1f0e710
4
- data.tar.gz: 20c1f1d0caed65c21c1f7d1b1b0b7401d3e198646173126ffd702a2f00cd0d4c
3
+ metadata.gz: 6063822db94053ab346b13e3bd90b843f62bb0cff1c86554b8999c147d29d5cb
4
+ data.tar.gz: 3c873ce53d2304093e6c1632aa439d72765c1373a2d154a48b102733c4580b93
5
5
  SHA512:
6
- metadata.gz: b88bc7b7e364e2c9bd96c776dcd172cec1d16abb74855d2592afae4cb7e038d295d55f90f6115158042926e143428e4f0e82e54b58d558bbd9056e021e667625
7
- data.tar.gz: d9441ab7c13079dc69063679f20e9bd7fffd46de94798a421b0660c0e0a389c7a9e0ba97fb07aa08a576b5565957002e5096131684a05a07ee51cd2425fbdce7
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
- Bump the version in `jobcelis.gemspec` and `lib/jobcelis.rb` and merge to `main`. The [Release workflow](.github/workflows/release.yml) publishes it to RubyGems and tags the commit. Changes that do not bump the version publish nothing.
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) }'`).
@@ -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
- # @param api_key [String] Your project API key.
16
- # @param base_url [String] Base URL of the Jobcelis API (default: https://jobcelis.com).
17
- # @param timeout [Integer] Request timeout in seconds (default: 30).
18
- def initialize(api_key:, base_url: "https://jobcelis.com", timeout: 30)
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
- post("/api/v1/events", { topic: topic, payload: payload, **kwargs })
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
- uri = build_uri(path)
656
- http = build_http(uri)
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
- resp = http.request(req)
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 request(method, path, params: nil, body: nil)
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
- http = build_http(uri)
703
+ payload = JSON.generate(body) if body
704
+ idempotent = method.upcase != "POST" || !idempotency_key.nil?
705
+ attempt = 0
669
706
 
670
- req = build_request(method, uri)
671
- req["Content-Type"] = "application/json"
672
- req["X-Api-Key"] = @api_key
673
- req["Authorization"] = "Bearer #{@auth_token}" if @auth_token
674
- req.body = JSON.generate(body) if body
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
- resp = http.request(req)
677
- handle_response(resp)
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 request_raw(method, path, params: nil)
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
- req["X-Api-Key"] = @api_key
687
- req["Authorization"] = "Bearer #{@auth_token}" if @auth_token
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
- resp.body
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
- unless resp.is_a?(Net::HTTPSuccess)
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
@@ -5,5 +5,5 @@ require_relative "jobcelis/webhook_verifier"
5
5
  require_relative "jobcelis/client"
6
6
 
7
7
  module Jobcelis
8
- VERSION = "1.3.0"
8
+ VERSION = "1.4.0"
9
9
  end
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.3.0
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-10-18 00:00:00.000000000 Z
11
+ date: 2026-11-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: net-http