jobcelis 1.2.1 → 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.
Files changed (6) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +67 -21
  3. data/README.md +23 -0
  4. data/lib/jobcelis/client.rb +130 -48
  5. data/lib/jobcelis.rb +1 -1
  6. metadata +17 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a04bededbf270f66972ecdf3e5762992eee208742f91405d08bb05c59ec8093a
4
- data.tar.gz: 3a15fc8b9891decb54f5b2daea5bd5eba3d70ea7c0fda0a2d78581aa8a944aa9
3
+ metadata.gz: 6063822db94053ab346b13e3bd90b843f62bb0cff1c86554b8999c147d29d5cb
4
+ data.tar.gz: 3c873ce53d2304093e6c1632aa439d72765c1373a2d154a48b102733c4580b93
5
5
  SHA512:
6
- metadata.gz: 4b5873ae607564ba60a02da3b9bec3ef477212d78c957db33f8ad4be8eaa7e597b603888218a68f7261febaaf0d15cbae118dc8ddad59a5815d6534c8c224db1
7
- data.tar.gz: 7686a3e2b909fe1df8b2c83d381b0a7b4da79b554bd912c6312b69f869fa0b5d4eb26030c87df0328b9e30d412c5cedb3aef3c8fe1df18effb8e9c27f287b74e
6
+ metadata.gz: 4935d8e1b10c18224ffb04a5d9908455720ed6e27a6093d4428b2116d153cb9814a6765cc0464abfdb8f791fdbe92f6562918f5be3f53da1d2bd735514b1e696
7
+ data.tar.gz: 85b68964a08b4b91df15f684eba19e5ea694e35ef317188358dfead5bc59ba7458a142cafa303c373f89d28f0ae27b6d15e1a775f1070a444bfc822dacc5d4e7
data/LICENSE CHANGED
@@ -1,21 +1,67 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 Jobcelis
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.
1
+ Business Source License 1.1
2
+
3
+ Parameters
4
+
5
+ Licensor: Vladimir Celi
6
+ Licensed Work: Jobcelis
7
+ The Licensed Work is (c) 2026 Vladimir Celi.
8
+ Additional Use Grant: You may make production use of the Licensed Work,
9
+ provided your use does not include offering the
10
+ Licensed Work to third parties as a hosted or
11
+ managed service, where the service provides users
12
+ with access to any substantial set of the features
13
+ or functionality of the Licensed Work.
14
+ Change Date: 2030-03-08
15
+ Change License: Apache License, Version 2.0
16
+
17
+ For information about alternative licensing arrangements for the Licensed Work,
18
+ please contact: support@jobcelis.com
19
+
20
+ Notice
21
+
22
+ Business Source License 1.1 (BUSL-1.1)
23
+
24
+ License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved.
25
+ "Business Source License" is a trademark of MariaDB Corporation Ab.
26
+
27
+ Terms
28
+
29
+ The Licensor hereby grants you the right to copy, modify, create derivative
30
+ works, redistribute, and make non-production use of the Licensed Work. The
31
+ Licensor may make an Additional Use Grant, above, permitting limited
32
+ production use.
33
+
34
+ Effective on the Change Date, or the fourth anniversary of the first publicly
35
+ available distribution of a specific version of the Licensed Work under this
36
+ License, whichever comes first, the Licensor hereby grants you rights under
37
+ the terms of the Change License, and the rights granted in the paragraph
38
+ above terminate.
39
+
40
+ If your use of the Licensed Work does not comply with the requirements
41
+ currently in effect as described in this License, you must purchase a
42
+ commercial license from the Licensor, its affiliated entities, or authorized
43
+ resellers, or you must refrain from using the Licensed Work.
44
+
45
+ All copies of the original and modified Licensed Work, and derivative works
46
+ of the Licensed Work, are subject to this License. This License applies
47
+ separately for each version of the Licensed Work and the Change Date may vary
48
+ for each version of the Licensed Work released by Licensor.
49
+
50
+ You must conspicuously display this License on each original or modified copy
51
+ of the Licensed Work. If you receive the Licensed Work in original or
52
+ modified form from a third party, the terms and conditions set forth in this
53
+ License apply to your use of that work.
54
+
55
+ Any use of the Licensed Work in violation of this License will automatically
56
+ terminate your rights under this License for the current and all other
57
+ versions of the Licensed Work.
58
+
59
+ This License does not grant you any right in any trademark or logo of
60
+ Licensor or its affiliates (provided that you may use a trademark or logo of
61
+ Licensor as expressly required by this License).
62
+
63
+ TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON
64
+ AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS,
65
+ EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF
66
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND
67
+ TITLE.
data/README.md CHANGED
@@ -248,6 +248,9 @@ topics = client.list_topics
248
248
  # API token
249
249
  token = client.get_token
250
250
  new_token = client.regenerate_token
251
+
252
+ # Limit the token to scopes and client IPs (empty allowed_ips = any IP)
253
+ client.update_token_restrictions(scopes: ["events:read", "events:write"], allowed_ips: ["203.0.113.10"])
251
254
  ```
252
255
 
253
256
  ## Multi-Project Management
@@ -346,6 +349,22 @@ rescue Jobcelis::Error => e
346
349
  end
347
350
  ```
348
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
+
349
368
  ## Webhook Signature Verification
350
369
 
351
370
  ```ruby
@@ -374,3 +393,7 @@ end
374
393
  ## License
375
394
 
376
395
  BSL-1.1 (Business Source License)
396
+
397
+ ## Releasing
398
+
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.
@@ -389,6 +417,12 @@ module Jobcelis
389
417
  post("/api/v1/token/regenerate", {})
390
418
  end
391
419
 
420
+ # Limit the API token to scopes and client IPs. Omitted arguments are left
421
+ # unchanged; an empty allowed_ips list means any IP.
422
+ def update_token_restrictions(scopes: nil, allowed_ips: nil)
423
+ patch("/api/v1/token", { scopes: scopes, allowed_ips: allowed_ips }.compact)
424
+ end
425
+
392
426
  # ------------------------------------------------------------------
393
427
  # Projects (multi)
394
428
  # ------------------------------------------------------------------
@@ -646,53 +680,95 @@ module Jobcelis
646
680
  end
647
681
 
648
682
  def public_post(path, body)
649
- uri = build_uri(path)
650
- http = build_http(uri)
651
-
652
- req = Net::HTTP::Post.new(uri)
653
- req["Content-Type"] = "application/json"
654
- req.body = JSON.generate(body)
683
+ resp = send_with_retries("POST", path, body: body, auth: false)
684
+ handle_response(resp)
685
+ end
655
686
 
656
- 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)
657
689
  handle_response(resp)
658
690
  end
659
691
 
660
- 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)
661
702
  uri = build_uri(path, params)
662
- http = build_http(uri)
703
+ payload = JSON.generate(body) if body
704
+ idempotent = method.upcase != "POST" || !idempotency_key.nil?
705
+ attempt = 0
663
706
 
664
- req = build_request(method, uri)
665
- req["Content-Type"] = "application/json"
666
- req["X-Api-Key"] = @api_key
667
- req["Authorization"] = "Bearer #{@auth_token}" if @auth_token
668
- 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
669
712
 
670
- resp = http.request(req)
671
- 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
672
723
  end
673
724
 
674
- def request_raw(method, path, params: nil)
675
- uri = build_uri(path, params)
725
+ def perform_request(method, uri, payload, auth, idempotency_key)
676
726
  http = build_http(uri)
677
-
678
727
  req = build_request(method, uri)
679
728
  req["Content-Type"] = "application/json"
680
- req["X-Api-Key"] = @api_key
681
- req["Authorization"] = "Bearer #{@auth_token}" if @auth_token
682
-
683
- resp = http.request(req)
684
-
685
- unless resp.is_a?(Net::HTTPSuccess)
686
- detail = begin
687
- parsed = JSON.parse(resp.body)
688
- parsed.is_a?(Hash) ? (parsed["error"] || parsed) : parsed
689
- rescue JSON::ParserError
690
- resp.body
691
- end
692
- 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
693
732
  end
733
+ req["X-Idempotency-Key"] = idempotency_key if idempotency_key
734
+ req.body = payload if payload
735
+ http.request(req)
736
+ end
694
737
 
695
- 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
696
772
  end
697
773
 
698
774
  def build_uri(path, params = nil)
@@ -709,6 +785,8 @@ module Jobcelis
709
785
  http.use_ssl = (uri.scheme == "https")
710
786
  http.open_timeout = @timeout
711
787
  http.read_timeout = @timeout
788
+ # Retries are handled by send_with_retries
789
+ http.max_retries = 0
712
790
  http
713
791
  end
714
792
 
@@ -725,19 +803,23 @@ module Jobcelis
725
803
  end
726
804
 
727
805
  def handle_response(resp)
728
- unless resp.is_a?(Net::HTTPSuccess)
729
- detail = begin
730
- parsed = JSON.parse(resp.body)
731
- parsed.is_a?(Hash) ? (parsed["error"] || parsed) : parsed
732
- rescue JSON::ParserError
733
- resp.body
734
- end
735
- raise Jobcelis::Error.new(resp.code.to_i, detail)
736
- end
806
+ raise_for_status(resp)
737
807
 
738
808
  return nil if resp.code == "204"
739
809
 
740
810
  JSON.parse(resp.body)
741
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
742
824
  end
743
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.2.1"
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.2.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-03-10 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
@@ -24,6 +24,20 @@ dependencies:
24
24
  - - ">="
25
25
  - !ruby/object:Gem::Version
26
26
  version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: base64
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '0.2'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '0.2'
27
41
  description: Ruby client for the Jobcelis API — events, webhooks, jobs, pipelines,
28
42
  and more. Connects to https://jobcelis.com by default.
29
43
  email: support@jobcelis.com
@@ -42,7 +56,7 @@ licenses:
42
56
  - BUSL-1.1
43
57
  metadata:
44
58
  homepage_uri: https://jobcelis.com
45
- source_code_uri: https://github.com/vladimirCeli/jobcelis-ruby
59
+ source_code_uri: https://github.com/jobcelis-team/jobcelis-ruby
46
60
  documentation_uri: https://jobcelis.com/docs
47
61
  post_install_message:
48
62
  rdoc_options: []