billdogeng 1.0.2.pre.beta.3

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.
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+ require "zlib"
7
+ require "stringio"
8
+
9
+ require_relative "errors"
10
+
11
+ module BilldogEng
12
+ # Thin HTTP client around Net::HTTP with:
13
+ # - per-request timeout
14
+ # - optional gzip request bodies (only when large enough to benefit)
15
+ # - exponential backoff retry (1s, 2s, 4s …) for 5xx / 429 / network errors
16
+ # - unwrapping of the `{ success, data, meta }` envelope (required on 2xx)
17
+ class Transport
18
+ GZIP_THRESHOLD_BYTES = 1024 # only compress bodies large enough to benefit
19
+
20
+ # @param host [String] base URL, e.g. "https://api.billdog.io/v1"
21
+ # @param request_timeout [Integer] per-request timeout in milliseconds
22
+ # @param gzip [Boolean] gzip large request bodies
23
+ # @param max_retries [Integer] retry attempts for transient failures
24
+ # @param enable_logging [Boolean] emit verbose diagnostics
25
+ # @param http_adapter [#call] optional injectable HTTP fn (for tests):
26
+ # `->(method, url, headers, body) { [status_int, body_string] }`
27
+ def initialize(host:, request_timeout:, gzip:, max_retries:, enable_logging:, http_adapter: nil)
28
+ @host = host.to_s.sub(%r{/+\z}, "")
29
+ @request_timeout = request_timeout
30
+ @gzip = gzip
31
+ @max_retries = max_retries
32
+ @enable_logging = enable_logging
33
+ @http_adapter = http_adapter
34
+ end
35
+
36
+ # Perform a POST (or GET when body is nil) request with retry/backoff.
37
+ # Returns the parsed JSON body, unwrapped from the standard envelope.
38
+ # Raises BilldogEng::Error after the final retry fails.
39
+ #
40
+ # @param path [String] path appended to host, e.g. "/ingest-events"
41
+ # @param body [Object] request body (serialized to JSON), or nil for GET
42
+ # @param headers [Hash] extra headers (auth supplied per-call)
43
+ # @param gzip [Boolean] override: gzip this request (only if config.gzip)
44
+ def request(path:, body: nil, headers: {}, gzip: true)
45
+ url = "#{@host}#{path}"
46
+ max_attempts = @max_retries + 1
47
+ last_error = nil
48
+
49
+ max_attempts.times do |attempt|
50
+ if attempt.positive?
51
+ backoff = 1.0 * (2**(attempt - 1)) # 1s, 2s, 4s …
52
+ log("retry #{attempt}/#{@max_retries} after #{backoff}s -> #{path}")
53
+ sleep(backoff)
54
+ end
55
+
56
+ begin
57
+ return attempt_request(url, body, headers, gzip)
58
+ rescue BilldogEng::Error => e
59
+ last_error = e
60
+ raise e if !retryable?(e.status) || attempt == max_attempts - 1
61
+
62
+ log("request failed (status #{e.status}), will retry: #{e.message}")
63
+ end
64
+ end
65
+
66
+ raise(last_error || BilldogEng::Error.new("request failed", status: 0))
67
+ end
68
+
69
+ private
70
+
71
+ def log(*args)
72
+ warn("[BilldogEng] #{args.join(" ")}") if @enable_logging
73
+ end
74
+
75
+ # Status codes that are safe to retry (transient).
76
+ def retryable?(status)
77
+ status.zero? || status == 408 || status == 429 || (status >= 500 && status <= 599)
78
+ end
79
+
80
+ def attempt_request(url, body, headers, gzip_opt)
81
+ req_headers = {
82
+ "Content-Type" => "application/json",
83
+ "User-Agent" => "billdogeng-ruby/#{BilldogEng::VERSION}",
84
+ }.merge(headers)
85
+
86
+ payload = nil
87
+ unless body.nil?
88
+ json = JSON.generate(body)
89
+ use_gzip = @gzip && gzip_opt != false && json.bytesize >= GZIP_THRESHOLD_BYTES
90
+ if use_gzip
91
+ req_headers["Content-Encoding"] = "gzip"
92
+ payload = gzip_compress(json)
93
+ else
94
+ payload = json
95
+ end
96
+ end
97
+
98
+ method = body.nil? ? "GET" : "POST"
99
+ status, text = perform(method, url, req_headers, payload)
100
+
101
+ parsed = parse_body(text)
102
+
103
+ unless status >= 200 && status < 300
104
+ # The nested error envelope: `{ success: false, error: { code, message } }`.
105
+ error = parsed.is_a?(Hash) && parsed["error"].is_a?(Hash) ? parsed["error"] : nil
106
+ message = (error && error["message"]) || "HTTP #{status}"
107
+ code = error && error["code"]
108
+ raise BilldogEng::Error.new(message, status: status, code: code, body: parsed)
109
+ end
110
+
111
+ unwrap_envelope(parsed, status)
112
+ end
113
+
114
+ # Returns [status_int, body_string].
115
+ def perform(method, url, headers, payload)
116
+ return @http_adapter.call(method, url, headers, payload) if @http_adapter
117
+
118
+ uri = URI.parse(url)
119
+ timeout_s = @request_timeout / 1000.0
120
+
121
+ http = Net::HTTP.new(uri.host, uri.port)
122
+ http.use_ssl = uri.scheme == "https"
123
+ http.open_timeout = timeout_s
124
+ http.read_timeout = timeout_s
125
+ http.write_timeout = timeout_s if http.respond_to?(:write_timeout=)
126
+
127
+ request =
128
+ if method == "GET"
129
+ Net::HTTP::Get.new(uri.request_uri)
130
+ else
131
+ Net::HTTP::Post.new(uri.request_uri)
132
+ end
133
+ headers.each { |k, v| request[k] = v }
134
+ request.body = payload if payload
135
+
136
+ begin
137
+ response = http.request(request)
138
+ rescue StandardError => e
139
+ # Network failure / timeout → status 0, retryable.
140
+ raise BilldogEng::Error.new("network error: #{e.message}", status: 0, code: "NETWORK_ERROR")
141
+ end
142
+
143
+ [response.code.to_i, response.body.to_s]
144
+ end
145
+
146
+ def parse_body(text)
147
+ return nil if text.nil? || text.empty?
148
+
149
+ begin
150
+ JSON.parse(text)
151
+ rescue JSON::ParserError
152
+ text
153
+ end
154
+ end
155
+
156
+ # Every endpoint answers the standard `{ success, data, meta }` envelope, and
157
+ # `data` is the payload. Anything else is not a BillDog answer and is refused,
158
+ # never read as a payload.
159
+ def unwrap_envelope(parsed, status)
160
+ # A quota refusal rides at the TOP level of the body, deliberately outside the
161
+ # envelope and with no `data`, so never unwrap past one: the caller has to see
162
+ # `quota_limited` to know the 200 it just got discarded its payload.
163
+ return parsed if parsed.is_a?(Hash) && parsed.key?("quota_limited")
164
+
165
+ return parsed["data"] if parsed.is_a?(Hash) && parsed["success"] == true && parsed.key?("data")
166
+
167
+ raise BilldogEng::Error.new(
168
+ "malformed response: expected the { success, data } envelope",
169
+ status: status, code: "MALFORMED_RESPONSE", body: parsed
170
+ )
171
+ end
172
+
173
+ def gzip_compress(str)
174
+ io = StringIO.new
175
+ io.set_encoding(Encoding::BINARY)
176
+ gz = Zlib::GzipWriter.new(io)
177
+ gz.write(str)
178
+ gz.close
179
+ io.string
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BilldogEng
4
+ VERSION = "1.0.2-beta.3"
5
+ end
data/lib/billdogeng.rb ADDED
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ # billdogeng — Official BilldogEng server SDK for Ruby.
4
+ #
5
+ # Engagement suite: Analytics + Feature Flags (remote + local eval) + Surveys +
6
+ # Messaging + LLM observability.
7
+ #
8
+ # @example
9
+ # require "billdogeng"
10
+ # bd = BilldogEng.new("bd_test_xxx", local_evaluation: true)
11
+ # bd.capture("user-123", "order_completed", { revenue: 49.99 })
12
+ # bd.shutdown
13
+
14
+ require_relative "billdogeng/version"
15
+ require_relative "billdogeng/errors"
16
+ require_relative "billdogeng/quota_limited"
17
+ require_relative "billdogeng/murmur"
18
+ require_relative "billdogeng/targeting"
19
+ require_relative "billdogeng/transport"
20
+ require_relative "billdogeng/analytics"
21
+ require_relative "billdogeng/flags"
22
+ require_relative "billdogeng/surveys"
23
+ require_relative "billdogeng/messaging"
24
+ require_relative "billdogeng/llm"
25
+ require_relative "billdogeng/client"
26
+
27
+ module BilldogEng
28
+ # Convenience constructor: `BilldogEng.new(api_key, **opts)` → BilldogEng::Client.
29
+ def self.new(api_key, opts = {})
30
+ Client.new(api_key, opts)
31
+ end
32
+
33
+ # Expose the bucketing primitive at the top level for cross-platform parity use.
34
+ def self.murmurhash3(key, seed = 0)
35
+ Murmur.murmurhash3(key, seed)
36
+ end
37
+ end
metadata ADDED
@@ -0,0 +1,90 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: billdogeng
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.2.pre.beta.3
5
+ platform: ruby
6
+ authors:
7
+ - BillDog
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-10-03 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rspec
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '3.13'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '3.13'
27
+ - !ruby/object:Gem::Dependency
28
+ name: webrick
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.8'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.8'
41
+ description: 'BilldogEng engagement suite for server-side Ruby: Analytics, Feature
42
+ Flags (remote + local evaluation), Surveys (data API), Messaging dispatch, and LLM
43
+ observability.'
44
+ email:
45
+ - sdk@billdog.io
46
+ executables: []
47
+ extensions: []
48
+ extra_rdoc_files: []
49
+ files:
50
+ - README.md
51
+ - lib/billdogeng.rb
52
+ - lib/billdogeng/analytics.rb
53
+ - lib/billdogeng/client.rb
54
+ - lib/billdogeng/errors.rb
55
+ - lib/billdogeng/flags.rb
56
+ - lib/billdogeng/llm.rb
57
+ - lib/billdogeng/messaging.rb
58
+ - lib/billdogeng/murmur.rb
59
+ - lib/billdogeng/quota_limited.rb
60
+ - lib/billdogeng/surveys.rb
61
+ - lib/billdogeng/targeting.rb
62
+ - lib/billdogeng/transport.rb
63
+ - lib/billdogeng/version.rb
64
+ homepage: https://billdog.io
65
+ licenses:
66
+ - MIT
67
+ metadata:
68
+ homepage_uri: https://billdog.io
69
+ source_code_uri: https://github.com/billdog/billdogeng-ruby
70
+ rubygems_mfa_required: 'true'
71
+ post_install_message:
72
+ rdoc_options: []
73
+ require_paths:
74
+ - lib
75
+ required_ruby_version: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - ">="
78
+ - !ruby/object:Gem::Version
79
+ version: 2.6.0
80
+ required_rubygems_version: !ruby/object:Gem::Requirement
81
+ requirements:
82
+ - - ">"
83
+ - !ruby/object:Gem::Version
84
+ version: 1.3.1
85
+ requirements: []
86
+ rubygems_version: 3.0.3.1
87
+ signing_key:
88
+ specification_version: 4
89
+ summary: Official BilldogEng server SDK for Ruby.
90
+ test_files: []