cronbeats-ruby 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: a49b421ceb99334edb0b7b1305d50c07a681d763f3d4733b89218445055c7960
4
+ data.tar.gz: e8d4ad7148ca5a62b30eed9ce2b6c8884b4a8a05dea497d850765a9c2db293c7
5
+ SHA512:
6
+ metadata.gz: 594248ec2d29731561af909b01ab81d37049c1900598348317e8f87607b9f4db6d14520bd044024ffcaadf8e6e136ce76c71e460a5fd2153d65691782226b83d
7
+ data.tar.gz: 920f785a34beb2adf3d2bf079239e451aae7d8b84fe8afc6c8298d4b754bdc8a60e538dfd90c44cdf163f9ad6ac82b6872e7fecade665bdcbf3b3aa7e4b095d5
data/Gemfile ADDED
@@ -0,0 +1,5 @@
1
+ source "https://rubygems.org"
2
+
3
+ gemspec
4
+
5
+ gem "rspec", "~> 3.13"
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 CronBeats
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,38 @@
1
+ # CronBeats Ruby SDK (Ping)
2
+
3
+ Official Ruby SDK for CronBeats ping telemetry.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ gem install cronbeats-ruby
9
+ ```
10
+
11
+ ## Quick Usage
12
+
13
+ ```ruby
14
+ require "cronbeats_ruby"
15
+
16
+ client = CronBeatsRuby::PingClient.new("abc123de")
17
+ client.start
18
+ # ...your work...
19
+ client.success
20
+ ```
21
+
22
+ ## Progress Updates
23
+
24
+ ```ruby
25
+ client.progress(50, "Processing batch 50/100")
26
+
27
+ client.progress({
28
+ seq: 75,
29
+ message: "Almost done"
30
+ })
31
+ ```
32
+
33
+ ## Notes
34
+
35
+ - SDK uses `POST` for telemetry requests.
36
+ - `jobKey` must be exactly 8 Base62 characters.
37
+ - Retries happen only for network errors, HTTP `429`, and HTTP `5xx`.
38
+ - Default 5s timeout ensures the SDK never blocks your cron job if CronBeats is unreachable.
@@ -0,0 +1,19 @@
1
+ module CronBeatsRuby
2
+ class SdkError < StandardError
3
+ end
4
+
5
+ class ValidationError < SdkError
6
+ end
7
+
8
+ class ApiError < SdkError
9
+ attr_reader :code, :http_status, :retryable, :raw
10
+
11
+ def initialize(code:, message:, http_status: nil, retryable: false, raw: nil)
12
+ super(message)
13
+ @code = code
14
+ @http_status = http_status
15
+ @retryable = retryable
16
+ @raw = raw
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,30 @@
1
+ require "json"
2
+ require "net/http"
3
+ require "uri"
4
+
5
+ module CronBeatsRuby
6
+ class NetHttpClient
7
+ def request(method:, url:, headers:, body:, timeout_ms:)
8
+ uri = URI(url)
9
+ http = Net::HTTP.new(uri.host, uri.port)
10
+ http.use_ssl = uri.scheme == "https"
11
+ timeout = [timeout_ms.to_f / 1000.0, 0.001].max
12
+ http.open_timeout = timeout
13
+ http.read_timeout = timeout
14
+ http.write_timeout = timeout if http.respond_to?(:write_timeout=)
15
+
16
+ req = Net::HTTP.const_get(method.capitalize).new(uri.request_uri)
17
+ headers.each { |k, v| req[k] = v }
18
+ req.body = body unless body.nil?
19
+
20
+ res = http.request(req)
21
+ {
22
+ status: res.code.to_i,
23
+ body: res.body.to_s,
24
+ headers: res.to_hash.transform_keys(&:downcase),
25
+ }
26
+ rescue StandardError => e
27
+ raise SdkError, e.message
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,184 @@
1
+ require "json"
2
+
3
+ module CronBeatsRuby
4
+ class PingClient
5
+ def initialize(job_key, options = {})
6
+ assert_job_key(job_key)
7
+
8
+ @job_key = job_key
9
+ @base_url = (options[:base_url] || "https://cronbeats.io").to_s.sub(%r{/+$}, "")
10
+ @timeout_ms = Integer(options[:timeout_ms] || 5000)
11
+ @max_retries = Integer(options[:max_retries] || 2)
12
+ @retry_backoff_ms = Integer(options[:retry_backoff_ms] || 250)
13
+ @retry_jitter_ms = Integer(options[:retry_jitter_ms] || 100)
14
+ @user_agent = (options[:user_agent] || "cronbeats-ruby-sdk/0.1.0").to_s
15
+ @http_client = options[:http_client] || NetHttpClient.new
16
+ end
17
+
18
+ def ping
19
+ request("ping", "/ping/#{@job_key}")
20
+ end
21
+
22
+ def start
23
+ request("start", "/ping/#{@job_key}/start")
24
+ end
25
+
26
+ def end(status = "success")
27
+ status_value = status.to_s.strip.downcase
28
+ unless %w[success fail].include?(status_value)
29
+ raise ValidationError, 'Status must be "success" or "fail".'
30
+ end
31
+
32
+ request("end", "/ping/#{@job_key}/end/#{status_value}")
33
+ end
34
+
35
+ def success
36
+ self.end("success")
37
+ end
38
+
39
+ def fail
40
+ self.end("fail")
41
+ end
42
+
43
+ def progress(seq_or_options = nil, message = nil)
44
+ seq = nil
45
+ msg = message
46
+
47
+ if seq_or_options.is_a?(Integer)
48
+ seq = seq_or_options
49
+ elsif seq_or_options.is_a?(Hash)
50
+ seq = seq_or_options.key?(:seq) ? Integer(seq_or_options[:seq]) : nil
51
+ msg = (seq_or_options[:message] || msg || "").to_s
52
+ end
53
+
54
+ if !seq.nil? && seq.negative?
55
+ raise ValidationError, "Progress seq must be a non-negative integer."
56
+ end
57
+
58
+ safe_msg = (msg || "").to_s
59
+ safe_msg = safe_msg[0, 255] if safe_msg.length > 255
60
+
61
+ unless seq.nil?
62
+ return request("progress", "/ping/#{@job_key}/progress/#{seq}", { message: safe_msg })
63
+ end
64
+
65
+ body = { message: safe_msg }
66
+ body[:progress] = seq_or_options if seq_or_options.is_a?(Integer)
67
+ request("progress", "/ping/#{@job_key}/progress", body)
68
+ end
69
+
70
+ private
71
+
72
+ def request(action, path, body = {})
73
+ attempt = 0
74
+ url = "#{@base_url}#{path}"
75
+
76
+ payload =
77
+ begin
78
+ body.empty? ? nil : JSON.generate(body)
79
+ rescue StandardError
80
+ raise SdkError, "Failed to encode request payload."
81
+ end
82
+
83
+ loop do
84
+ begin
85
+ response = @http_client.request(
86
+ method: "post",
87
+ url: url,
88
+ headers: {
89
+ "Content-Type" => "application/json",
90
+ "Accept" => "application/json",
91
+ "User-Agent" => @user_agent,
92
+ },
93
+ body: payload,
94
+ timeout_ms: @timeout_ms
95
+ )
96
+ rescue SdkError => e
97
+ if attempt >= @max_retries
98
+ raise ApiError.new(
99
+ code: "NETWORK_ERROR",
100
+ http_status: nil,
101
+ retryable: true,
102
+ message: e.message,
103
+ raw: e
104
+ )
105
+ end
106
+
107
+ attempt += 1
108
+ sleep_with_backoff(attempt)
109
+ next
110
+ end
111
+
112
+ status = response[:status].to_i
113
+ decoded = safe_json(response[:body].to_s)
114
+
115
+ if status >= 200 && status < 300
116
+ return normalize_success(action, decoded)
117
+ end
118
+
119
+ error = map_error(status)
120
+ if error[:retryable] && attempt < @max_retries
121
+ attempt += 1
122
+ sleep_with_backoff(attempt)
123
+ next
124
+ end
125
+
126
+ raise ApiError.new(
127
+ code: error[:code],
128
+ http_status: status,
129
+ retryable: error[:retryable],
130
+ message: (decoded["message"] || "Request failed").to_s,
131
+ raw: decoded
132
+ )
133
+ end
134
+ end
135
+
136
+ def normalize_success(action, payload)
137
+ {
138
+ "ok" => true,
139
+ "action" => (payload["action"] || action).to_s,
140
+ "jobKey" => (payload["job_key"] || @job_key).to_s,
141
+ "timestamp" => (payload["timestamp"] || "").to_s,
142
+ "processingTimeMs" => float_or_zero(payload["processing_time_ms"]),
143
+ "nextExpected" => payload.key?("next_expected") ? payload["next_expected"]&.to_s : nil,
144
+ "raw" => payload,
145
+ }
146
+ end
147
+
148
+ def map_error(status)
149
+ return { code: "VALIDATION_ERROR", retryable: false } if status == 400
150
+ return { code: "NOT_FOUND", retryable: false } if status == 404
151
+ return { code: "RATE_LIMITED", retryable: true } if status == 429
152
+ return { code: "SERVER_ERROR", retryable: true } if status >= 500
153
+
154
+ { code: "UNKNOWN_ERROR", retryable: false }
155
+ end
156
+
157
+ def assert_job_key(job_key)
158
+ return if /\A[a-zA-Z0-9]{8}\z/.match?(job_key.to_s)
159
+
160
+ raise ValidationError, "jobKey must be exactly 8 Base62 characters."
161
+ end
162
+
163
+ def sleep_with_backoff(attempt)
164
+ base_ms = @retry_backoff_ms * (2**[attempt - 1, 0].max)
165
+ jitter_ms = rand(0..[@retry_jitter_ms, 0].max)
166
+ Kernel.sleep((base_ms + jitter_ms) / 1000.0)
167
+ end
168
+
169
+ def safe_json(raw)
170
+ parsed = JSON.parse(raw)
171
+ return parsed if parsed.is_a?(Hash)
172
+ rescue JSON::ParserError
173
+ nil
174
+ ensure
175
+ return({ "message" => "Invalid JSON response" }) if defined?(parsed).nil? || !parsed.is_a?(Hash)
176
+ end
177
+
178
+ def float_or_zero(value)
179
+ Float(value)
180
+ rescue StandardError
181
+ 0.0
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,3 @@
1
+ module CronBeatsRuby
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,7 @@
1
+ require_relative "cronbeats_ruby/version"
2
+ require_relative "cronbeats_ruby/errors"
3
+ require_relative "cronbeats_ruby/http"
4
+ require_relative "cronbeats_ruby/ping_client"
5
+
6
+ module CronBeatsRuby
7
+ end
@@ -0,0 +1,175 @@
1
+ require "json"
2
+ require "cronbeats_ruby"
3
+
4
+ RSpec.describe CronBeatsRuby::PingClient do
5
+ class StubHttpClient
6
+ attr_reader :calls
7
+
8
+ def initialize(responses: [], network_failures: 0)
9
+ @responses = responses
10
+ @network_failures = network_failures
11
+ @calls = []
12
+ end
13
+
14
+ def request(method:, url:, headers:, body:, timeout_ms:)
15
+ @calls << { method: method, url: url, body: body, timeout_ms: timeout_ms, headers: headers }
16
+
17
+ if @network_failures.positive?
18
+ @network_failures -= 1
19
+ raise CronBeatsRuby::SdkError, "socket timeout"
20
+ end
21
+
22
+ @responses.shift || { status: 200, body: "{}", headers: {} }
23
+ end
24
+ end
25
+
26
+ it "rejects invalid job key" do
27
+ expect { described_class.new("invalid-key") }.to raise_error(CronBeatsRuby::ValidationError)
28
+ end
29
+
30
+ it "normalizes success response" do
31
+ stub = StubHttpClient.new(
32
+ responses: [
33
+ {
34
+ status: 200,
35
+ body: JSON.generate(
36
+ {
37
+ status: "success",
38
+ message: "OK",
39
+ action: "ping",
40
+ job_key: "abc123de",
41
+ timestamp: "2026-02-25 12:00:00",
42
+ processing_time_ms: 8.25,
43
+ }
44
+ ),
45
+ headers: {},
46
+ },
47
+ ]
48
+ )
49
+ client = described_class.new("abc123de", http_client: stub)
50
+ result = client.ping
51
+ expect(result["ok"]).to eq(true)
52
+ expect(result["action"]).to eq("ping")
53
+ expect(result["jobKey"]).to eq("abc123de")
54
+ expect(result["processingTimeMs"]).to eq(8.25)
55
+ end
56
+
57
+ it "maps 404 to NOT_FOUND" do
58
+ stub = StubHttpClient.new(
59
+ responses: [
60
+ { status: 404, body: JSON.generate({ status: "error", message: "Job not found" }), headers: {} },
61
+ ]
62
+ )
63
+ client = described_class.new("abc123de", http_client: stub, max_retries: 0)
64
+ expect { client.ping }.to raise_error(CronBeatsRuby::ApiError) { |err|
65
+ expect(err.code).to eq("NOT_FOUND")
66
+ expect(err.retryable).to eq(false)
67
+ expect(err.http_status).to eq(404)
68
+ }
69
+ end
70
+
71
+ it "retries on 429 and then succeeds" do
72
+ stub = StubHttpClient.new(
73
+ responses: [
74
+ { status: 429, body: JSON.generate({ status: "error", message: "Too many requests" }), headers: {} },
75
+ {
76
+ status: 200,
77
+ body: JSON.generate(
78
+ {
79
+ status: "success",
80
+ message: "OK",
81
+ action: "ping",
82
+ job_key: "abc123de",
83
+ timestamp: "2026-02-25 12:00:00",
84
+ processing_time_ms: 7.1,
85
+ }
86
+ ),
87
+ headers: {},
88
+ },
89
+ ]
90
+ )
91
+ client = described_class.new(
92
+ "abc123de",
93
+ http_client: stub,
94
+ max_retries: 2,
95
+ retry_backoff_ms: 1,
96
+ retry_jitter_ms: 0
97
+ )
98
+ result = client.ping
99
+ expect(result["ok"]).to eq(true)
100
+ expect(stub.calls.length).to eq(2)
101
+ end
102
+
103
+ it "does not retry on 400" do
104
+ stub = StubHttpClient.new(
105
+ responses: [
106
+ { status: 400, body: JSON.generate({ status: "error", message: "Invalid request" }), headers: {} },
107
+ ]
108
+ )
109
+ client = described_class.new("abc123de", http_client: stub, max_retries: 2)
110
+ expect { client.ping }.to raise_error(CronBeatsRuby::ApiError) { |err|
111
+ expect(err.code).to eq("VALIDATION_ERROR")
112
+ expect(err.retryable).to eq(false)
113
+ }
114
+ expect(stub.calls.length).to eq(1)
115
+ end
116
+
117
+ it "retries on network errors and then succeeds" do
118
+ stub = StubHttpClient.new(
119
+ responses: [
120
+ {
121
+ status: 200,
122
+ body: JSON.generate(
123
+ {
124
+ status: "success",
125
+ message: "OK",
126
+ action: "ping",
127
+ job_key: "abc123de",
128
+ timestamp: "2026-02-25 12:00:00",
129
+ processing_time_ms: 4.2,
130
+ }
131
+ ),
132
+ headers: {},
133
+ },
134
+ ],
135
+ network_failures: 1
136
+ )
137
+ client = described_class.new(
138
+ "abc123de",
139
+ http_client: stub,
140
+ max_retries: 2,
141
+ retry_backoff_ms: 1,
142
+ retry_jitter_ms: 0
143
+ )
144
+ result = client.ping
145
+ expect(result["ok"]).to eq(true)
146
+ expect(stub.calls.length).to eq(2)
147
+ end
148
+
149
+ it "normalizes progress and truncates message to 255" do
150
+ stub = StubHttpClient.new(
151
+ responses: [
152
+ {
153
+ status: 200,
154
+ body: JSON.generate(
155
+ {
156
+ status: "success",
157
+ message: "OK",
158
+ action: "progress",
159
+ job_key: "abc123de",
160
+ timestamp: "2026-02-25 12:00:00",
161
+ processing_time_ms: 8,
162
+ }
163
+ ),
164
+ headers: {},
165
+ },
166
+ ]
167
+ )
168
+ long_msg = "x" * 300
169
+ client = described_class.new("abc123de", http_client: stub)
170
+ client.progress({ seq: 50, message: long_msg })
171
+ expect(stub.calls[0][:url]).to end_with("/ping/abc123de/progress/50")
172
+ sent = JSON.parse(stub.calls[0][:body])
173
+ expect(sent["message"].length).to eq(255)
174
+ end
175
+ end
metadata ADDED
@@ -0,0 +1,55 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: cronbeats-ruby
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - CronBeats
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-02-25 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: Ruby client for CronBeats ping, start/end, and progress telemetry APIs.
14
+ email:
15
+ - support@cronbeats.com
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - Gemfile
21
+ - LICENSE
22
+ - README.md
23
+ - lib/cronbeats_ruby.rb
24
+ - lib/cronbeats_ruby/errors.rb
25
+ - lib/cronbeats_ruby/http.rb
26
+ - lib/cronbeats_ruby/ping_client.rb
27
+ - lib/cronbeats_ruby/version.rb
28
+ - spec/ping_client_spec.rb
29
+ homepage: https://github.com/cronbeats/cronbeats-ruby
30
+ licenses:
31
+ - MIT
32
+ metadata:
33
+ homepage_uri: https://github.com/cronbeats/cronbeats-ruby
34
+ source_code_uri: https://github.com/cronbeats/cronbeats-ruby
35
+ bug_tracker_uri: https://github.com/cronbeats/cronbeats-ruby/issues
36
+ post_install_message:
37
+ rdoc_options: []
38
+ require_paths:
39
+ - lib
40
+ required_ruby_version: !ruby/object:Gem::Requirement
41
+ requirements:
42
+ - - ">="
43
+ - !ruby/object:Gem::Version
44
+ version: '3.0'
45
+ required_rubygems_version: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - ">="
48
+ - !ruby/object:Gem::Version
49
+ version: '0'
50
+ requirements: []
51
+ rubygems_version: 3.2.33
52
+ signing_key:
53
+ specification_version: 4
54
+ summary: Official CronBeats Ping SDK for Ruby.
55
+ test_files: []