coolhand 0.5.1 → 0.6.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.
@@ -4,9 +4,19 @@ require "net/http"
4
4
  require "uri"
5
5
  require "json"
6
6
  require_relative "collector"
7
+ require_relative "errors"
8
+ require_relative "read_requests"
7
9
 
8
10
  module Coolhand
9
11
  class ApiService
12
+ # The GET half of this class. See Coolhand::ReadRequests for why reads raise where writes
13
+ # log and return nil.
14
+ include ReadRequests
15
+
16
+ # Caps how much of a failed response body is interpolated into a log line or an exception
17
+ # message. Without it an oversized body from a proxy or gateway becomes the message.
18
+ ERROR_BODY_LIMIT = 2000
19
+
10
20
  attr_reader :api_endpoint
11
21
 
12
22
  def initialize(endpoint = "v2/llm_request_logs")
@@ -77,12 +87,7 @@ module Coolhand
77
87
  http.read_timeout = 5
78
88
 
79
89
  request = Net::HTTP::Post.new(uri.request_uri)
80
- headers = create_request_options(payload)
81
- headers.each do |key, value|
82
- # Ensure header values are UTF-8 encoded
83
- encoded_value = value.is_a?(String) ? value.dup.force_encoding("UTF-8") : value
84
- request[key] = encoded_value
85
- end
90
+ apply_headers(request, create_request_options(payload))
86
91
 
87
92
  # Clean payload and ensure UTF-8 encoding before JSON generation
88
93
  cleaned_payload = sanitize_payload_for_json(payload)
@@ -105,14 +110,7 @@ module Coolhand
105
110
  log success_message
106
111
  result
107
112
  else
108
- body = response.body.force_encoding("UTF-8") if response.body
109
- # Only show first part of HTML error pages
110
- error_msg = if body&.include?("<!DOCTYPE html>")
111
- "#{body[0..200]}... [HTML error page truncated]"
112
- else
113
- body
114
- end
115
- log "❌ Request failed: #{response.code} - #{error_msg}"
113
+ log "❌ Request failed: #{response.code} - #{format_error_body(response.body)}"
116
114
  nil
117
115
  end
118
116
  rescue StandardError => e
@@ -210,6 +208,26 @@ module Coolhand
210
208
 
211
209
  private
212
210
 
211
+ # Shared by the write path's failure log and the read path's raised message, so a large
212
+ # response body never dumps a whole document into either. An HTML error page (a proxy's 502,
213
+ # say) is cut short hard; anything else keeps enough to diagnose from and no more.
214
+ def format_error_body(body)
215
+ return nil if body.nil?
216
+
217
+ text = body.dup.force_encoding("UTF-8")
218
+ return "#{text[0..200]}... [HTML error page truncated]" if text.include?("<!DOCTYPE html>")
219
+ return text if text.length <= ERROR_BODY_LIMIT
220
+
221
+ "#{text[0, ERROR_BODY_LIMIT]}... [truncated]"
222
+ end
223
+
224
+ def apply_headers(request, headers)
225
+ headers.each do |key, value|
226
+ # Net::HTTP rejects header values that are not UTF-8.
227
+ request[key] = value.is_a?(String) ? value.dup.force_encoding("UTF-8") : value
228
+ end
229
+ end
230
+
213
231
  def missing_api_key?
214
232
  return false if Coolhand.required_field?(api_key)
215
233
 
@@ -281,8 +299,24 @@ module Coolhand
281
299
  return if silent
282
300
 
283
301
  puts "\n🎉 LOGGING OpenAI API Call #{@api_endpoint}"
284
- puts captured_data
302
+
303
+ if debug_mode?
304
+ puts captured_data
305
+ else
306
+ puts request_body_summary(captured_data)
307
+ end
308
+
285
309
  puts "📤 Sending to: #{@api_endpoint}"
286
310
  end
311
+
312
+ def request_body_summary(captured_data)
313
+ return "captured_data: (unavailable)" unless captured_data.is_a?(Hash)
314
+
315
+ id = captured_data[:id] || captured_data["id"] || "N/A"
316
+ body = captured_data[:request_body] || captured_data["request_body"]
317
+ "id: #{id}, request_body: #{body.to_json.bytesize} bytes"
318
+ rescue StandardError
319
+ "captured_data: (unavailable)"
320
+ end
287
321
  end
288
322
  end
@@ -23,7 +23,9 @@ module Coolhand
23
23
  begin
24
24
  headers.to_hash.transform_keys(&:to_s).transform_values { |v| normalize_header_value(v) }
25
25
  rescue StandardError
26
- # fall through to other enumeration strategies
26
+ # Deliberately fails closed to an empty hash rather than trying each_header/each on
27
+ # this object next — an object whose own to_hash raises is untrustworthy enough that
28
+ # guessing at another enumeration strategy isn't worth the risk of a second failure.
27
29
  nil
28
30
  end
29
31
  elsif headers.respond_to?(:each_header)
@@ -80,25 +82,33 @@ module Coolhand
80
82
 
81
83
  def sanitize_url(url)
82
84
  uri = URI.parse(url)
83
- return url unless uri.query
84
-
85
- params = URI.decode_www_form(uri.query)
86
- redacted = false
87
- params.map! do |n, v|
88
- if n.match?(SENSITIVE_QUERY_PARAM_PATTERN)
89
- redacted = true
90
- [n, "[REDACTED]"]
91
- else
92
- [n, v]
93
- end
85
+ modified = false
86
+
87
+ if uri.userinfo
88
+ # URI userinfo syntax disallows "[" / "]", so this can't reuse the [REDACTED]
89
+ # placeholder used elsewhere.
90
+ uri.userinfo = "REDACTED"
91
+ modified = true
94
92
  end
95
93
 
96
- if redacted
97
- uri.query = URI.encode_www_form(params)
98
- uri.to_s
99
- else
100
- url
94
+ if uri.query
95
+ params = URI.decode_www_form(uri.query)
96
+ redacted_query = false
97
+ params.map! do |n, v|
98
+ if n.match?(SENSITIVE_QUERY_PARAM_PATTERN)
99
+ redacted_query = true
100
+ [n, "[REDACTED]"]
101
+ else
102
+ [n, v]
103
+ end
104
+ end
105
+ if redacted_query
106
+ uri.query = URI.encode_www_form(params)
107
+ modified = true
108
+ end
101
109
  end
110
+
111
+ modified ? uri.to_s : url
102
112
  rescue URI::InvalidURIError
103
113
  url
104
114
  end
@@ -3,6 +3,8 @@
3
3
  require "yaml"
4
4
  require "uri"
5
5
 
6
+ require_relative "open_ai/webhook_id_store"
7
+
6
8
  module Coolhand
7
9
  # Handles all configuration settings for the gem.
8
10
  class Configuration
@@ -14,11 +16,21 @@ module Coolhand
14
16
  File.join(__dir__, "default_intercept_addresses.yml")
15
17
  ).freeze
16
18
 
19
+ DEFAULT_INTERCEPT_PATH_PATTERNS = YAML.safe_load_file(
20
+ File.join(__dir__, "default_intercept_path_patterns.yml")
21
+ ).freeze
22
+
17
23
  BASE_URL_ERROR_MSG = "base_url must use https:// (or http://localhost / http://127.0.0.1 for local dev)"
18
24
  LOOPBACK_HOSTS = %w[localhost 127.0.0.1 ::1].freeze
19
25
 
20
- attr_accessor :api_key, :environment, :silent, :debug_mode, :capture, :exclude_api_patterns, :enabled
21
- attr_reader :intercept_addresses, :base_url
26
+ # 1 MB generous for real chat/completion payloads, small enough to stop
27
+ # a multi-MB file upload from being logged in full when its content-type
28
+ # happens to look JSON-ish (or is unset).
29
+ DEFAULT_MAX_CAPTURED_BODY_BYTES = 1_000_000
30
+
31
+ attr_accessor :api_key, :environment, :silent, :debug_mode, :capture, :exclude_api_patterns, :enabled,
32
+ :max_captured_body_bytes, :webhook_replay_tolerance_seconds, :webhook_id_store
33
+ attr_reader :intercept_addresses, :intercept_path_patterns, :base_url
22
34
 
23
35
  def initialize
24
36
  # Set defaults
@@ -26,20 +38,41 @@ module Coolhand
26
38
  @api_key = nil
27
39
  @silent = false
28
40
  @intercept_addresses = DEFAULT_INTERCEPT_ADDRESSES.dup
41
+ @intercept_path_patterns = DEFAULT_INTERCEPT_PATH_PATTERNS.dup
29
42
  self.base_url = "https://coolhandlabs.com/api"
30
43
  @debug_mode = false
31
44
  @capture = true
32
45
  @exclude_api_patterns = DEFAULT_EXCLUDE_API_PATTERNS.dup
33
46
  @enabled = true
47
+ @max_captured_body_bytes = DEFAULT_MAX_CAPTURED_BODY_BYTES
48
+ @webhook_replay_tolerance_seconds = 300
49
+ @webhook_id_store = Coolhand::OpenAi::WebhookIdStore.new
34
50
  end
35
51
 
36
- # Custom setter that preserves defaults when nil/empty array is provided
52
+ # intercept_addresses is a required allow-list: NetHttpInterceptor#intercept? only
53
+ # captures a request whose URL matches an entry here, so an empty list would mean
54
+ # "never capture anything" (validate! deliberately rejects that). Unlike
55
+ # exclude_api_patterns, `= []` is not a supported way to disable it — use
56
+ # config.enabled = false or config.capture = false to disable capture entirely.
57
+ # nil/empty here is treated as "leave the current value alone" rather than cleared.
37
58
  def intercept_addresses=(value)
38
- return if value.nil? || (value.is_a?(Array) && value.empty?)
59
+ if value.nil? || (value.is_a?(Array) && value.empty?)
60
+ Coolhand.log "⚠️ Coolhand: intercept_addresses = #{value.inspect} is ignored " \
61
+ "(would disable capture entirely) — keeping the current value. " \
62
+ "Use config.enabled = false or config.capture = false to disable capture."
63
+ return
64
+ end
39
65
 
40
66
  @intercept_addresses = value.is_a?(Array) ? value : [value]
41
67
  end
42
68
 
69
+ # Custom setter that preserves defaults when nil/empty array is provided
70
+ def intercept_path_patterns=(value)
71
+ return if value.nil? || (value.is_a?(Array) && value.empty?)
72
+
73
+ @intercept_path_patterns = value.is_a?(Array) ? value : [value]
74
+ end
75
+
43
76
  def base_url=(value)
44
77
  stripped = value&.sub(%r{/+\z}, "")
45
78
  raise Error, BASE_URL_ERROR_MSG unless stripped.nil? || valid_base_url?(stripped)
@@ -1,6 +1,7 @@
1
1
  # Coolhand default exclude API patterns
2
- # These substrings are matched against request URLs after the intercept_addresses
3
- # allow-list passes. Matching URLs are skipped and not forwarded as llm_request_logs.
2
+ # These substrings are matched against the request's path (not the full URL or
3
+ # query string). A request whose path matches is skipped and not forwarded as
4
+ # an llm_request_log, regardless of whether it would otherwise match intercept_addresses.
4
5
  #
5
6
  # Users can extend defaults: c.exclude_api_patterns << "/myOperationalPath/"
6
7
  # Users can override entirely: c.exclude_api_patterns = ["/only_this/"]
@@ -1,9 +1,15 @@
1
1
  # Coolhand default intercept addresses
2
- # These substrings are matched against request URLs to decide whether a request
3
- # should be captured and forwarded as an llm_request_log.
2
+ # These are matched against the request's parsed host (exact match, or a
3
+ # dot-boundary suffix match, case-insensitive) to decide whether a request
4
+ # should be captured and forwarded as an llm_request_log. A single "*" in an
5
+ # entry matches exactly one host label — e.g. "bedrock-runtime.*.amazonaws.com"
6
+ # matches "bedrock-runtime.us-east-1.amazonaws.com" but nothing else.
4
7
  #
5
8
  # Users can extend defaults: c.intercept_addresses << "my.custom.api.com"
6
9
  # Users can override entirely: c.intercept_addresses = ["only.this.com"]
10
+ # This list cannot be emptied — it's required (unlike exclude_api_patterns, which
11
+ # can be disabled with `= []`). To disable capture entirely, use
12
+ # c.enabled = false or c.capture = false instead.
7
13
 
8
14
  - "api.openai.com"
9
15
  - "api.anthropic.com"
@@ -11,9 +17,8 @@
11
17
  - "generativelanguage.googleapis.com"
12
18
  - "models.github.ai"
13
19
  - "models.inference.ai.azure.com"
14
- - ":generateContent"
15
- - ":streamGenerateContent"
16
20
  - "aiplatform.googleapis.com"
17
21
  - "gateway.ai.cloudflare.com"
18
- - "bedrock-runtime"
22
+ - "bedrock-runtime.*.amazonaws.com"
19
23
  - "openrouter.ai"
24
+ - "opencode.ai"
@@ -0,0 +1,12 @@
1
+ # Colon-action path suffixes from Google's API Discovery convention
2
+ # (e.g. ".../v1beta/models/gemini-pro:generateContent"). These have no host
3
+ # component of their own, so — unlike intercept_addresses — they are only
4
+ # ever matched against the *path* of requests whose host is already
5
+ # googleapis.com or a googleapis.com subdomain. They can never match on
6
+ # their own against an unrelated/attacker-controlled host.
7
+ #
8
+ # Users can extend defaults: c.intercept_path_patterns << ":myAction"
9
+ # Users can override entirely: c.intercept_path_patterns = [":onlyThis"]
10
+
11
+ - ":generateContent"
12
+ - ":streamGenerateContent"
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coolhand
4
+ class Error < StandardError; end
5
+
6
+ # Raised by the gem's read methods when the Coolhand API answers with a non-2xx status.
7
+ #
8
+ # `status` is carried so callers can branch on it (404 vs retryable 504) without matching the
9
+ # message text.
10
+ class HttpError < Error
11
+ attr_reader :status, :body
12
+
13
+ def initialize(message, status:, body: nil)
14
+ super(message)
15
+ @status = status
16
+ @body = body
17
+ end
18
+ end
19
+ end
@@ -44,7 +44,7 @@ module Coolhand
44
44
  headers: sanitize_headers(headers),
45
45
  request_body: clean_webhook_body(webhook_body, source),
46
46
  response_body: options[:response_body],
47
- response_headers: options[:response_headers],
47
+ response_headers: options[:response_headers] && sanitize_headers(options[:response_headers]),
48
48
  status_code: options[:status_code] || 200,
49
49
  source: "#{source}_webhook"
50
50
  }.merge(options.slice(:metadata, :conversation_id, :agent_id))
@@ -24,27 +24,69 @@ module Coolhand
24
24
  end
25
25
  end
26
26
 
27
+ @patch_mutex = Mutex.new
28
+ @patch_count = 0
29
+ @patched = false
30
+
31
+ # patch!/unpatch! are reference-counted so concurrent/nested callers (e.g. overlapping
32
+ # Coolhand.capture blocks across threads) compose safely — the interceptor only actually
33
+ # unpatches once every outstanding caller has released it. Coolhand.configure's patch! is
34
+ # never balanced by an unpatch!, so it permanently holds the count at >=1 for the process
35
+ # lifetime once the gem is enabled; that's intentional, not a leak.
36
+ #
37
+ # Unlike before, patch! is no longer idempotent on its own — every call must be matched by
38
+ # exactly one unpatch! to release it. A host app that calls Coolhand.configure more than once
39
+ # (e.g. a reloader re-running an initializer) will hold an extra, permanent reference each
40
+ # time rather than no-op'ing; harmless (interception simply stays on), but worth knowing.
27
41
  def self.patch!
28
- return if @patched
29
-
30
- Net::HTTP.prepend(self)
31
- Net::HTTPResponse.prepend(ResponseInterceptor)
32
-
33
- @patched = true
34
- Coolhand.log "🔗 Net::HTTP interceptor patched"
42
+ @patch_mutex.synchronize do
43
+ @patch_count += 1
44
+ next if @patched
45
+
46
+ begin
47
+ Net::HTTP.prepend(self)
48
+ Net::HTTPResponse.prepend(ResponseInterceptor)
49
+
50
+ @patched = true
51
+ Coolhand.log "🔗 Net::HTTP interceptor patched"
52
+ rescue StandardError
53
+ # Roll back this call's hold — it never actually took effect, so it must not count
54
+ # toward the refcount or a legitimate later unpatch! would underflow against it. This
55
+ # branch only runs when @patched was false on entry (see `next if @patched` above), so
56
+ # forcing it back to false here is correct regardless of which line above raised —
57
+ # including a failure in the log call itself, after prepend already succeeded.
58
+ @patch_count -= 1
59
+ @patched = false
60
+ raise
61
+ end
62
+ end
35
63
  end
36
64
 
37
65
  def self.unpatch!
38
66
  # NOTE: With prepend, there's no clean way to unpatch
39
67
  # We'll mark it as unpatched so it can be re-patched
40
- @patched = false
41
- Coolhand.log "🔌 Faraday monitoring disabled ..."
68
+ @patch_mutex.synchronize do
69
+ @patch_count -= 1 if @patch_count.positive?
70
+ next if @patch_count.positive? || !@patched
71
+
72
+ @patched = false
73
+ Coolhand.log "🔌 Faraday monitoring disabled ..."
74
+ end
42
75
  end
43
76
 
44
77
  def self.patched?
45
78
  @patched
46
79
  end
47
80
 
81
+ # Testing-only: force a clean slate. Real callers should only ever use balanced
82
+ # patch!/unpatch! pairs.
83
+ def self.reset!
84
+ @patch_mutex.synchronize do
85
+ @patch_count = 0
86
+ @patched = false
87
+ end
88
+ end
89
+
48
90
  def request(req, body = nil, &block)
49
91
  return super unless NetHttpInterceptor.patched?
50
92
 
@@ -131,16 +173,41 @@ module Coolhand
131
173
  end
132
174
 
133
175
  def capture_request_body(req, body)
134
- return parse_json(body) if body
135
- return parse_json(req.body) if req.body
136
-
137
- if req.respond_to?(:body_stream) && req.body_stream
176
+ # Check content-type before touching body_stream at all — for a binary
177
+ # upload (multipart/form-data, audio/*, etc.) this avoids reading the
178
+ # stream into memory a second time just to build a log entry no one
179
+ # can read anyway.
180
+ return skipped_capture_marker(req, "non_json_content_type") if binary_upload?(req)
181
+
182
+ content = body || req.body
183
+ if content.nil? && req.respond_to?(:body_stream) && req.body_stream
138
184
  content = req.body_stream.read
139
185
  req.body_stream = StringIO.new(content)
140
- return parse_json(content)
141
186
  end
187
+ return nil if content.nil?
142
188
 
143
- nil
189
+ cap_and_parse(content, req)
190
+ end
191
+
192
+ def binary_upload?(req)
193
+ content_type = req.respond_to?(:content_type) ? req.content_type : nil
194
+ content_type && !content_type.match?(/json/i)
195
+ end
196
+
197
+ def cap_and_parse(content, req)
198
+ max_bytes = Coolhand.configuration.max_captured_body_bytes
199
+ if max_bytes && content.bytesize > max_bytes
200
+ return skipped_capture_marker(req, "body_too_large", size_bytes: content.bytesize, max_bytes: max_bytes)
201
+ end
202
+
203
+ parse_json(content)
204
+ end
205
+
206
+ def skipped_capture_marker(req, reason, extra = {})
207
+ marker = { "_coolhand_capture_skipped" => reason }.merge(extra.transform_keys(&:to_s))
208
+ content_type = req.respond_to?(:content_type) ? req.content_type : nil
209
+ marker["content_type"] = content_type if content_type
210
+ marker
144
211
  end
145
212
 
146
213
  def extract_status_from_exception(e)
@@ -153,22 +220,62 @@ module Coolhand
153
220
 
154
221
  def intercept?(url)
155
222
  return false unless url && Coolhand.configuration.respond_to?(:intercept_addresses)
156
- return false if excluded_by_pattern?(url)
157
223
 
158
- Coolhand.configuration.intercept_addresses.any? { |a| url.include?(a) }
224
+ uri = safe_parse(url)
225
+ return false unless uri&.host
226
+
227
+ return false if excluded_by_pattern?(uri)
228
+
229
+ host = uri.host.downcase
230
+ addresses = Coolhand.configuration.intercept_addresses
231
+ return true if addresses.any? { |a| host_matches?(host, a) }
232
+
233
+ return false unless google_api_host_configured?(addresses)
234
+ return false unless host == "googleapis.com" || host.end_with?(".googleapis.com")
235
+
236
+ path = uri.path.to_s
237
+ Coolhand.configuration.intercept_path_patterns.any? { |p| path.include?(p) }
159
238
  end
160
239
 
161
- def excluded_by_pattern?(url)
240
+ def excluded_by_pattern?(uri)
162
241
  patterns = Coolhand.configuration.exclude_api_patterns
163
242
  return false if patterns.nil? || patterns.empty?
164
243
 
165
- matched = patterns.find { |pattern| url.include?(pattern) }
244
+ path = uri.path.to_s
245
+ matched = patterns.find { |pattern| path.include?(pattern) }
166
246
  if matched && Coolhand.configuration.debug_mode
167
- Coolhand.log "🚫 Skipping capture for #{sanitize_url(url)} (matched exclude_api_pattern: \"#{matched}\")"
247
+ Coolhand.log "🚫 Skipping capture for #{sanitize_url(uri.to_s)} (matched exclude_api_pattern: \"#{matched}\")"
168
248
  end
169
249
  !!matched
170
250
  end
171
251
 
252
+ # intercept_path_patterns only ever applies to googleapis.com hosts, and only when the
253
+ # user still wants Google API traffic intercepted at all — otherwise overriding
254
+ # intercept_addresses to exclude Google hosts wouldn't actually stop Google API capture.
255
+ def google_api_host_configured?(addresses)
256
+ addresses.any? do |a|
257
+ a = a.to_s.downcase
258
+ a == "googleapis.com" || a.end_with?(".googleapis.com")
259
+ end
260
+ end
261
+
262
+ # Host-boundary match: exact, or a dot-delimited suffix (case-insensitive).
263
+ # A single "*" in `pattern` matches exactly one host label, e.g.
264
+ # "bedrock-runtime.*.amazonaws.com" matches "bedrock-runtime.us-east-1.amazonaws.com".
265
+ def host_matches?(host, pattern)
266
+ pattern = pattern.to_s.downcase
267
+ return host == pattern || host.end_with?(".#{pattern}") unless pattern.include?("*")
268
+
269
+ regex = /\A#{pattern.split('*', -1).map { |part| Regexp.escape(part) }.join('[^.]+')}\z/
270
+ !!(host =~ regex)
271
+ end
272
+
273
+ def safe_parse(url)
274
+ URI.parse(url)
275
+ rescue URI::InvalidURIError
276
+ nil
277
+ end
278
+
172
279
  def build_url_for_request(http, req)
173
280
  return req.path if %r{\Ahttps?://}.match?(req.path)
174
281
 
@@ -98,7 +98,7 @@ module Coolhand
98
98
  id: request_id,
99
99
  timestamp: timestamp,
100
100
  method: method.to_s.downcase,
101
- url: url,
101
+ url: BaseInterceptor.sanitize_url(url),
102
102
  headers: {},
103
103
  request_body: request_body,
104
104
  response_headers: {},
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coolhand
4
+ module OpenAi
5
+ # Thread-safe, in-memory, TTL-bounded store used to detect replayed
6
+ # `webhook-id` values. This is the default for
7
+ # `Coolhand.configuration.webhook_id_store` and only dedupes within a
8
+ # single process - deployments running multiple processes/dynos should
9
+ # supply their own store (e.g. backed by Rails.cache) via configuration.
10
+ class WebhookIdStore
11
+ def initialize
12
+ @entries = {}
13
+ @mutex = Mutex.new
14
+ end
15
+
16
+ # Atomically checks-and-records `id` in a single critical section, so
17
+ # two concurrent replays of the same id can't both observe "unseen"
18
+ # before either records it. Returns true the first time `id` is
19
+ # claimed, false if it was already claimed within its TTL.
20
+ def claim!(id, ttl_seconds)
21
+ @mutex.synchronize do
22
+ prune
23
+ return false if @entries.key?(id)
24
+
25
+ @entries[id] = Time.now.to_i + ttl_seconds
26
+ true
27
+ end
28
+ end
29
+
30
+ def seen?(id)
31
+ @mutex.synchronize do
32
+ prune
33
+ @entries.key?(id)
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def prune
40
+ now = Time.now.to_i
41
+ @entries.delete_if { |_id, expires_at| expires_at < now }
42
+ end
43
+ end
44
+ end
45
+ end