camada 0.1.2 → 0.1.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 450a1a841ac03fb9490c0a6c1c3e45d62b0eec927795bb3fc3b2064f89432349
4
- data.tar.gz: 8cd0ffee6f1686a7ad8150094898f3faaaff7f7b797306b5bb2af936a05ccc84
3
+ metadata.gz: 66753d06b68709a8360519bc64f67cc2278112b804da0d83bebfbe74ab211ae6
4
+ data.tar.gz: fc1423d70058d49223d3a3462d882fc6898013266c130d4be2835bb5696e9f5b
5
5
  SHA512:
6
- metadata.gz: 27a5225cff7c94d36f2d5d1d85e91a4304bc71d7edff69c5706ffb9cba903132e67723e650f991a1445abd96f402ac3b2fa920240bc6184e82441870dc0a94cb
7
- data.tar.gz: 677d632f45dab7351fd8982c396335a9ab02b8bfc1f6598301b4b1c334d520922d6b11e543353e314b8c5aead17b0b66ae4aefbaac6e5ccec0a9e773aa066f58
6
+ metadata.gz: 94a9c22c64a0f100c47c6312bc81cd9d4cf0b38a73d30f901a7951815b42b9f111d617f69acd2fa5541945b9626a4259c9ad39e5a2021fd7c8a6a03d38d30809
7
+ data.tar.gz: 07d7bfc337bc64e69781e507efcd1269acbd79e626608e27631aec91004a4de820ad80bc5ba3fbe02d3192005a8f1278fd7825a66df31ea6b7063f87c13c5f9a
data/README.md CHANGED
@@ -176,7 +176,9 @@ here (and never raises), while it does at the edge.
176
176
 
177
177
  Every entry point runs inside the fail-open envelope: a dead ingest drops telemetry (logged at
178
178
  most once a minute, one line, no backtrace), a corrupt snapshot keeps the previous one, a bug in
179
- the gem costs the request its join, never its response. `CAMADA_DISABLED=1` bypasses everything.
179
+ the gem costs the request its join, never its response. A failed snapshot poll (401, 5xx, no
180
+ answer) keeps the rules in place and paces the next one: `Retry-After` in seconds is honoured,
181
+ floored at 5 s and capped at the refresh interval. `CAMADA_DISABLED=1` bypasses everything.
180
182
 
181
183
  ## Development
182
184
 
@@ -23,6 +23,21 @@ module Camada
23
23
  # Process.pid with the pid that built the client and starts over in a forked worker.
24
24
  COLD = MatchResult.new(reason: "cold") # never loaded yet: fail open, mirrors the collector
25
25
  NONE = MatchResult.new
26
+ POLL_FLOOR_S = 5.0
27
+
28
+ # Seconds to wait before the next self-initiated poll after a poll answered `status`, or nil
29
+ # when the answer was 200/204/304 (not paced: the normal cadence governs). A Retry-After in
30
+ # delta-seconds is honoured, floored at 5 s and capped at the current cadence (the cap wins).
31
+ # HTTP-dates and anything else that is not all digits read as absent (edge-analyst only sends
32
+ # delta-seconds). status 0 = no answer. Internal.
33
+ def self.next_poll_delay(status, retry_after, refresh_s)
34
+ return nil if [200, 204, 304].include?(status)
35
+
36
+ ra = retry_after.to_s.gsub(/\A[ \t]+|[ \t]+\z/, "")
37
+ secs = ra.match?(/\A[0-9]+\z/) ? [ra.to_i, 1_000_000_000].min : 0 # overflow reads as huge, not invalid
38
+ cap = [refresh_s.to_f, 0.0].max
39
+ [[secs, POLL_FLOOR_S].max, cap].min.to_f # rubocop:disable Style/ComparableClamp -- not #clamp: it raises when the cap is under the floor
40
+ end
26
41
 
27
42
  class Client
28
43
  attr_accessor :transport
@@ -46,6 +61,7 @@ module Camada
46
61
  @pinned = !refresh_s.nil?
47
62
  @etag = nil
48
63
  @loaded_at = nil
64
+ @not_before = 0.0 # a failed poll gates the next self-initiated one until this monotonic time
49
65
  fresh_state!
50
66
  end
51
67
 
@@ -81,24 +97,55 @@ module Camada
81
97
  @loaded_at.nil? || monotonic - @loaded_at > @refresh_s * 0.9
82
98
  end
83
99
 
84
- # Kicks a refresh when stale; never blocks the request path, never raises.
100
+ # Kicks a refresh when due; never blocks the request path, never raises.
85
101
  def ensure_fresh
86
102
  check_fork!
87
- return if !stale? || @loading.locked?
103
+ return if !due? || @loading.locked?
88
104
 
89
- t = Thread.new { refresh }
105
+ t = Thread.new { refresh_if_due }
90
106
  t.name = "camada-snapshot-load"
91
107
  t.report_on_exception = false
92
108
  nil
93
109
  end
94
110
 
95
- # One synchronous poll (single in-flight): what the threads call, and what tests and warm-ups call directly.
111
+ # One synchronous poll (single in-flight): what tests and warm-ups call directly, ignoring the gate.
96
112
  def refresh
113
+ poll { true }
114
+ end
115
+
116
+ # Cold (never loaded) and no-snapshot both fail open, mirroring the edge collector.
117
+ def verdict(i)
118
+ return COLD if @loaded_at.nil?
119
+
120
+ m = @matcher
121
+ m ? m.match(i) : NONE
122
+ end
123
+
124
+ private
125
+
126
+ # What the background kick calls: take the slot, then re-check, so a second kick that lost the
127
+ # race to a poll that has just finished (and gated the next one) does nothing.
128
+ def refresh_if_due
129
+ poll { due? }
130
+ end
131
+
132
+ # Stale and past the failure gate: what every self-initiated poll (request path, timer tick)
133
+ # asks. A gate further away than one cadence means the clock stepped; treat it as open.
134
+ def due?
135
+ return false unless stale?
136
+
137
+ now = monotonic
138
+ @not_before <= now || @not_before - now > @refresh_s
139
+ end
140
+
141
+ def poll
97
142
  check_fork!
98
143
  lock = @loading # bound once: after_fork! swaps the attribute
99
144
  return unless lock.try_lock
100
145
 
101
146
  begin
147
+ return unless yield
148
+
102
149
  load_once
103
150
  rescue StandardError => e # a poll that can never succeed must not be silent, nor fatal
104
151
  Guarded.log_rate_limited(e)
@@ -107,16 +154,6 @@ module Camada
107
154
  end
108
155
  end
109
156
 
110
- # Cold (never loaded) and no-snapshot both fail open, mirroring the edge collector.
111
- def verdict(i)
112
- return COLD if @loaded_at.nil?
113
-
114
- m = @matcher
115
- m ? m.match(i) : NONE
116
- end
117
-
118
- private
119
-
120
157
  def fresh_state!
121
158
  @pid = Process.pid
122
159
  @loading = Mutex.new
@@ -150,8 +187,18 @@ module Camada
150
187
  headers["x-camada-sdk"] = @sdk if @sdk
151
188
  # a tenant without that container is answered with the next one down
152
189
  headers["x-camada-snapshot"] = @snapshot_version.to_s if @snapshot_version > 3
153
- res = @transport.call(HttpRequest.new(method: "GET", url: @url, headers: headers, body: nil, timeout_s: @timeout_s))
154
- return unless [200, 204, 304].include?(res.status) # 401/5xx/network: keep what we have
190
+ res = begin
191
+ @transport.call(HttpRequest.new(method: "GET", url: @url, headers: headers, body: nil, timeout_s: @timeout_s))
192
+ rescue StandardError => e
193
+ Guarded.log_rate_limited(e) # still logged (rate-limited), and gated below as status 0
194
+ HttpResponse.new(status: 0, headers: {}, body: "".b) # a transport that raises is no answer
195
+ end
196
+ delay = Snapshot.next_poll_delay(res.status, res.headers["retry-after"], @refresh_s)
197
+ unless delay.nil? # 401/5xx/network: keep what we have (cold stays cold) and back off
198
+ @not_before = monotonic + delay # written before the slot is released
199
+ return
200
+ end
201
+ @not_before = 0.0
155
202
 
156
203
  # loaded_at is stamped last (even when the body turns out corrupt): "not cold" is what Puma's
157
204
  # request threads read as "rules in place", so it must not be visible before the matcher and
@@ -42,11 +42,11 @@ module Camada
42
42
  end
43
43
 
44
44
  def self.response(status, headers, body)
45
- if headers["content-encoding"].to_s.downcase == "gzip"
45
+ if !body.empty? && headers["content-encoding"].to_s.downcase == "gzip"
46
46
  begin
47
47
  body = Zlib::GzipReader.new(StringIO.new(body)).read.b
48
48
  rescue StandardError
49
- return HttpResponse.new(status: 0, headers: headers, body: "".b) # a body we cannot read is no answer at all
49
+ return HttpResponse.new(status: 0, headers: {}, body: "".b) # a body we cannot read is no answer at all: no retry-after either
50
50
  end
51
51
  headers.delete("content-encoding")
52
52
  end
@@ -5,6 +5,6 @@ module Camada
5
5
  # source: camada.gemspec reads it at build, and the sibling drift guards (camada-backend,
6
6
  # camada-web, camada-mkt) parse this file with /^\s*VERSION = "([^"]+)"/m the way they parse
7
7
  # a package.json. Plain X.Y.Z only: the analyst's SDK_RE drops anything else.
8
- VERSION = "0.1.2"
8
+ VERSION = "0.1.3"
9
9
  SDK_ID = "@camada/ruby/#{VERSION}".freeze
10
10
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: camada
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - camada
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-04 00:00:00.000000000 Z
11
+ date: 2026-10-09 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: 'camada SDK for Ruby: inline enforcement of your snapshot (ordered custom
14
14
  rules, then allow, block, challenge), first-party beacon and proof-of-work challenge,