calmlens 0.1.1 → 0.17.1

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.
@@ -10,12 +10,8 @@ module CalmLens
10
10
  # The resources share one session per client. It never prints the API key.
11
11
  # @api private
12
12
  class Session
13
- MAX_JSON_BYTES = 32 * 1024 * 1024
14
- MAX_ERROR_BYTES = 1024 * 1024
15
- MAX_RETRY_DELAY = 60
16
- MAX_BACKOFF = 8
17
- ENSURE_LENSES_MAX_PER_REQUEST = 100
18
- USER_AGENT = "calmlens-ruby/#{VERSION} (ruby #{RUBY_VERSION})".freeze
13
+ RETRYABLE_STATUSES = [429, 502, 503, 504].freeze
14
+ USER_AGENT = "calmlens-ruby/#{VERSION} (ruby/#{RUBY_VERSION})".freeze
19
15
 
20
16
  # Raised through the transport so an exception from the caller's streaming block is
21
17
  # re-raised unchanged instead of being mistaken for a network failure.
@@ -29,9 +25,12 @@ module CalmLens
29
25
  end
30
26
 
31
27
  attr_reader :project_id, :base_url, :cdn_base_url
28
+ # Leaves the upload size checks the API also makes (part and image sizes) to the API, so an
29
+ # offline stand-in answers them like the API does. {MockServerClient} sets it.
30
+ attr_accessor :defer_upload_checks
32
31
 
33
32
  def initialize(api_key:, project_id:, base_url:, cdn_base_url:, timeout:, open_timeout:, max_retries:,
34
- retry_base_delay:, transport:)
33
+ retry_base_delay:, max_response_bytes:, upload_timeout:, transport:)
35
34
  @api_key = api_key
36
35
  @project_id = project_id
37
36
  @base_url = base_url
@@ -40,6 +39,8 @@ module CalmLens
40
39
  @open_timeout = open_timeout
41
40
  @max_retries = max_retries
42
41
  @retry_base_delay = retry_base_delay
42
+ @max_response_bytes = max_response_bytes
43
+ @upload_timeout = upload_timeout
43
44
  @transport = transport
44
45
  @lenses = {}
45
46
  @lens_lock = Mutex.new
@@ -47,17 +48,32 @@ module CalmLens
47
48
 
48
49
  def inspect = "#<#{self.class.name} project_id=#{@project_id.inspect} api_key=[REDACTED]>"
49
50
 
51
+ # Seconds on a monotonic clock, for decision deadlines. {MockServerClient} replaces it.
52
+ def monotonic = @monotonic ? @monotonic.call : Process.clock_gettime(Process::CLOCK_MONOTONIC)
53
+
54
+ # Waits between retries and decision checks. {MockServerClient} replaces it.
55
+ def pause(seconds) = @pause ? @pause.call(seconds) : sleep(seconds)
56
+
57
+ # Replaces the clock and wait used between retries and decision checks, so a mock's waits take no
58
+ # real time.
59
+ def use_clock(monotonic:, pause:)
60
+ @monotonic = monotonic
61
+ @pause = pause
62
+ end
63
+
50
64
  def close
51
65
  @transport.close if @transport.respond_to?(:close)
52
66
  end
53
67
 
54
68
  def project(path) = "/projects/#{Internal.segment(@project_id)}/#{path}"
55
69
 
56
- # Sends an API request and returns the parsed JSON body.
70
+ # Sends an API request and returns the parsed JSON body (nil for an empty 204 or DELETE response).
57
71
  #
58
72
  # @param idempotent [Boolean] send an `Idempotency-Key` (generated unless the caller gave one),
59
73
  # reused across retries so the API runs the request at most once.
60
- def request(method, path, request_options:, query: {}, body: nil, raw: nil, headers: {}, idempotent: false)
74
+ # @param upload [Boolean] a binary upload: `upload_timeout`, when set, limits each attempt.
75
+ def request(method, path, request_options:, query: {}, body: nil, raw: nil, headers: {}, idempotent: false,
76
+ upload: false)
61
77
  url = @base_url + path
62
78
  params = Internal.query(query)
63
79
  url += "?#{URI.encode_www_form(params)}" unless params.empty?
@@ -66,20 +82,25 @@ module CalmLens
66
82
  raw = JSON.generate(body)
67
83
  headers['Content-Type'] = 'application/json'
68
84
  end
69
- if raw.is_a?(UploadBody)
85
+ raw = UploadBody.new(raw) if raw && !raw.is_a?(UploadBody)
86
+ if raw&.size
70
87
  headers['Content-Length'] = raw.size.to_s
71
88
  elsif raw
72
- raw = UploadBody.new(raw)
89
+ headers['Transfer-Encoding'] = 'chunked'
73
90
  end
74
91
  headers['Idempotency-Key'] = request_options[:idempotency_key] || SecureRandom.uuid if idempotent || request_options[:idempotency_key]
75
- execute(method, url, headers, raw, request_options) { |response| parse(response) }
92
+ limit = request_options.fetch(:max_response_bytes, @max_response_bytes)
93
+ deadline = request_options.fetch(:upload_timeout, @upload_timeout) if upload
94
+ execute(method, url, headers, raw, request_options, deadline: deadline) { |response| parse(method, response, limit) }
76
95
  end
77
96
 
78
- # Sends a CDN request. With a block, yields the streaming {Response} and returns the block's
97
+ # Sends a CDN request, retrying like an API read, and returns the last response rather than
98
+ # raising an error status. With a block, yields the streaming {Response} and returns the block's
79
99
  # value; otherwise returns the response with its body read.
80
100
  def cdn(method, url, headers, request_options, &block)
81
- execute(method, url, headers, nil, request_options, stream: !block.nil?) do |response|
82
- next response.tap(&:body) unless block
101
+ limit = request_options.fetch(:max_response_bytes, nil)
102
+ execute(method, url, headers, nil, request_options, allow_http_errors: true) do |response|
103
+ next response.tap { response.body(limit: limit) } unless block
83
104
 
84
105
  streaming = Response.new(status: response.status, headers: response.headers) do |&sink|
85
106
  response.each_chunk do |chunk|
@@ -99,17 +120,17 @@ module CalmLens
99
120
  end
100
121
 
101
122
  # Resolves lens definitions, sending only definitions this client has not saved before, at most
102
- # ENSURE_LENSES_MAX_PER_REQUEST per request, in order.
123
+ # ServerClient::ENSURE_LENSES_MAX_PER_REQUEST per request, in order. Calls are serialized, so concurrent calls
124
+ # for the same definitions send them once.
103
125
  def ensure_lenses(lenses, request_options)
104
126
  if !lenses.is_a?(Array) || lenses.empty? || lenses.any? { |lens| !lens.is_a?(Hash) || lens['identifier'].to_s.empty? }
105
127
  raise Internal.invalid('At least one lens with an identifier is required')
106
128
  end
107
129
 
108
130
  @lens_lock.synchronize do
109
- keys = lenses.map { |lens| JSON.generate(lens.sort.to_h) }
131
+ keys = lenses.map { |lens| lens_key(lens) }
110
132
  missing = keys.zip(lenses).to_h.reject { |key, _| @lenses.key?(key) }
111
- resolved = @lenses.dup
112
- missing.each_slice(ENSURE_LENSES_MAX_PER_REQUEST) do |slice|
133
+ missing.each_slice(ServerClient::ENSURE_LENSES_MAX_PER_REQUEST) do |slice|
113
134
  chunk = slice.to_h
114
135
  returned = request('PUT', project('lenses'), body: { 'lenses' => chunk.values },
115
136
  request_options: request_options)
@@ -118,56 +139,113 @@ module CalmLens
118
139
  raise InvalidResponseError.new(SdkErrorCodes::INVALID_RESPONSE, 'Unexpected lens response')
119
140
  end
120
141
 
121
- resolved.merge!(chunk.keys.zip(returned).to_h)
122
142
  # A new version of a lens replaces the cached definition with the same identifier.
123
143
  chunk.keys.zip(returned).each do |key, lens|
124
144
  @lenses.delete_if { |_, saved| saved['identifier'] == lens['identifier'] }
125
145
  @lenses[key] = lens
126
146
  end
127
147
  end
128
- JSON.parse(JSON.generate(keys.map { |key| resolved.fetch(key) }))
129
- end
130
- end
148
+ keys.map do |key|
149
+ raise InvalidResponseError.new(SdkErrorCodes::INVALID_RESPONSE, 'CalmLens did not return a lens') unless @lenses.key?(key)
131
150
 
132
- # Whether the saved lenses line up, one for one and in order, with the definitions that were sent.
133
- def lenses_match?(returned, sent)
134
- returned.is_a?(Array) && returned.size == sent.size &&
135
- returned.zip(sent).all? { |saved, definition| saved.is_a?(Hash) && saved['identifier'] == definition['identifier'] }
151
+ JSON.parse(JSON.generate(@lenses[key]))
152
+ end
153
+ end
136
154
  end
137
155
 
138
156
  def headers_for(extra)
139
- extra.reject { |name, _| %w[x-api-key user-agent].include?(name.downcase) }
157
+ extra.reject { |name, _| %w[x-api-key user-agent authorization].include?(name.downcase) }
140
158
  .merge('X-Api-Key' => @api_key, 'User-Agent' => USER_AGENT)
141
159
  end
142
160
 
143
161
  private
144
162
 
145
- def execute(method, url, headers, body, request_options, stream: false, &handler)
163
+ # The fields that identify a lens definition, as canonical JSON: an unset description or
164
+ # transforms list is the same definition as an explicit null.
165
+ def lens_key(lens)
166
+ fields = { 'identifier' => lens['identifier'], 'name' => lens['name'], 'description' => lens['description'],
167
+ 'type' => lens['type'], 'transforms' => lens['transforms'] }
168
+ %w[active version].each { |name| fields[name] = lens[name] if lens.key?(name) }
169
+ JSON.generate(canonical(fields))
170
+ end
171
+
172
+ def canonical(value)
173
+ case value
174
+ when Hash then value.sort_by { |key, _| key.to_s }.to_h { |key, item| [key.to_s, canonical(item)] }
175
+ when Array then value.map { |item| canonical(item) }
176
+ else value
177
+ end
178
+ end
179
+
180
+ # Whether the saved lenses line up, one for one and in order, with the definitions that were sent.
181
+ def lenses_match?(returned, sent)
182
+ returned.is_a?(Array) && returned.size == sent.size &&
183
+ returned.zip(sent).all? { |saved, definition| saved.is_a?(Hash) && saved['identifier'] == definition['identifier'] }
184
+ end
185
+
186
+ # Sends a request, retrying transport failures, timeouts, 429, 502–504 and
187
+ # `idempotency/request-in-progress` when it is safe to repeat: reads, and requests with an
188
+ # `Idempotency-Key` whose body can be sent again. Redirects are never followed.
189
+ def execute(method, url, headers, body, request_options, allow_http_errors: false, deadline: nil, &handler)
146
190
  retries = request_options.fetch(:max_retries, @max_retries)
147
- retry_allowed = %w[GET HEAD].include?(method) || headers.key?('Idempotency-Key')
191
+ retryable = retries.positive? && (%w[GET HEAD].include?(method) || headers.key?('Idempotency-Key')) &&
192
+ (body.nil? || body.replayable?)
148
193
  request = Transport::Request.new(method: method, url: url, headers: headers_for(headers), body: body&.content,
149
194
  open_timeout: request_options.fetch(:open_timeout, @open_timeout),
150
195
  timeout: request_options.fetch(:timeout, @timeout))
196
+ limit = request_options.fetch(:max_response_bytes, @max_response_bytes)
151
197
  attempt = 0
152
- begin
198
+ loop do
153
199
  body&.rewind if attempt.positive?
154
- delivered = false
155
- send_request(request) do |response|
156
- delivered = true if stream
157
- handler.call(response)
200
+ context = { again: retryable && attempt < retries, attempt: attempt, limit: limit, deadline: deadline,
201
+ allow_http_errors: allow_http_errors }
202
+ outcome, value = send_attempt(request, context, &handler)
203
+ if outcome == :error
204
+ delay = http_delay(value.retry_after, attempt)
205
+ raise value unless context[:again] && delay <= ServerClient::RETRY_AFTER_MAX && value.retryable?
206
+
207
+ outcome = :retry
208
+ value = delay
158
209
  end
159
- rescue Error => e
160
- raise unless retry_allowed && !delivered && attempt < retries && e.retryable? && (body.nil? || body.replayable?)
161
-
162
- delay = retry_delay(attempt, e.retry_after)
163
- raise if delay.nil?
210
+ return value if outcome == :ok
164
211
 
165
- sleep(delay)
212
+ pause(value)
166
213
  attempt += 1
167
- retry
168
214
  end
169
215
  end
170
216
 
217
+ # One attempt: `[:ok, result]`, `[:error, error]` for an error response, or `[:retry, seconds]`.
218
+ # A response returned to the caller (or its streaming block) is never retried.
219
+ def send_attempt(request, context, &handler)
220
+ delivered = false
221
+ within(context[:deadline]) do
222
+ send_request(request) do |response|
223
+ raise redirect_error(response) if redirect?(response)
224
+
225
+ if context[:allow_http_errors] && !response.success?
226
+ delay = http_delay(Error.parse_retry_after(response.headers['retry-after']), context[:attempt])
227
+ retry_status = RETRYABLE_STATUSES.include?(response.status) && delay <= ServerClient::RETRY_AFTER_MAX
228
+ next [:retry, delay] if context[:again] && retry_status
229
+ end
230
+ next [:error, http_error(response, context[:limit])] unless response.success? || context[:allow_http_errors]
231
+
232
+ delivered = true
233
+ [:ok, handler.call(response)]
234
+ end
235
+ end
236
+ rescue Error => e
237
+ raise unless context[:again] && !delivered && e.retryable?
238
+
239
+ [:retry, backoff(context[:attempt])]
240
+ end
241
+
242
+ # Runs one attempt within an upload deadline, when there is one.
243
+ def within(deadline, &)
244
+ deadline ? ::Timeout.timeout(deadline, &) : yield
245
+ rescue ::Timeout::Error => e
246
+ raise TimeoutError.new(SdkErrorCodes::TIMEOUT, 'Request timed out'), cause: e
247
+ end
248
+
171
249
  def send_request(request, &handler)
172
250
  yielded = false
173
251
  result = nil
@@ -185,9 +263,9 @@ module CalmLens
185
263
  rescue Error
186
264
  raise
187
265
  rescue ::Timeout::Error => e
188
- raise TimeoutError.new(SdkErrorCodes::TIMEOUT, e.message), cause: e
266
+ raise TimeoutError.new(SdkErrorCodes::TIMEOUT, 'Request timed out'), cause: e
189
267
  rescue *Transport::ERRORS => e
190
- raise ConnectionError.new(SdkErrorCodes::TRANSPORT_ERROR, e.message), cause: e
268
+ raise ConnectionError.new(SdkErrorCodes::TRANSPORT_ERROR, "Request failed: #{e.message}"), cause: e
191
269
  rescue ArgumentError, URI::Error => e
192
270
  raise InvalidArgumentError.new(SdkErrorCodes::INVALID_ARGUMENT, e.message), cause: e
193
271
  end
@@ -201,45 +279,69 @@ module CalmLens
201
279
  Response.new(status: status, headers: headers, body: value.body.to_s)
202
280
  end
203
281
 
204
- def retry_delay(attempt, retry_after)
205
- return (retry_after <= MAX_RETRY_DELAY ? retry_after : nil) if retry_after
282
+ # A redirect the SDK must not follow: it would send the API key to another origin.
283
+ def redirect?(response) = response.status.between?(300, 399) && response.headers.key?('location')
206
284
 
207
- rand * [@retry_base_delay * (2**attempt), MAX_BACKOFF].min
285
+ def redirect_error(response)
286
+ Error.build(SdkErrorCodes::HTTP_ERROR, "Unexpected redirect (HTTP #{response.status})", status: response.status,
287
+ headers: response.headers)
208
288
  end
209
289
 
210
- def parse(response)
211
- text = response.body(limit: response.success? ? MAX_JSON_BYTES : MAX_ERROR_BYTES).dup.force_encoding(Encoding::UTF_8)
212
- begin
213
- data = text.empty? ? nil : JSON.parse(text)
214
- rescue JSON::ParserError
215
- raise invalid_body(response) if response.success?
290
+ # Exponential backoff with jitter: half the delay is fixed, the other half random.
291
+ def backoff(attempt)
292
+ delay = [@retry_base_delay * (2**attempt), ServerClient::RETRY_MAX_DELAY].min
293
+ ((delay / 2.0) + (rand * delay / 2.0)).round(3)
294
+ end
216
295
 
217
- data = nil
218
- end
219
- return data if response.success?
296
+ def http_delay(retry_after, attempt) = retry_after.nil? ? backoff(attempt) : retry_after
220
297
 
221
- raise api_error(response, data)
222
- rescue InvalidResponseError
223
- raise if response.success?
298
+ def read(response, limit)
299
+ declared = response.content_length
300
+ raise too_large(response, limit) if declared && declared > limit
224
301
 
225
- raise api_error(response, nil)
302
+ response.body(limit: limit)
303
+ rescue InvalidResponseError => e
304
+ raise e unless e.code == SdkErrorCodes::RESPONSE_TOO_LARGE
305
+
306
+ raise too_large(response, limit)
226
307
  end
227
308
 
228
- def invalid_body(response)
229
- InvalidResponseError.new(SdkErrorCodes::INVALID_RESPONSE, "Invalid JSON response (HTTP #{response.status})",
230
- headers: response.headers)
309
+ def too_large(response, limit)
310
+ InvalidResponseError.new(SdkErrorCodes::RESPONSE_TOO_LARGE, "Response body exceeds #{limit} bytes",
311
+ status: response.status, headers: response.headers)
231
312
  end
232
313
 
233
- def api_error(response, data)
314
+ def parse(method, response, limit)
315
+ text = read(response, limit).dup.force_encoding(Encoding::UTF_8)
316
+ return nil if text.empty? && (response.status == 204 || method == 'DELETE')
317
+
318
+ JSON.parse(text)
319
+ rescue JSON::ParserError, EncodingError
320
+ raise InvalidResponseError.new(SdkErrorCodes::INVALID_RESPONSE, "Invalid JSON response (HTTP #{response.status})",
321
+ status: response.status, headers: response.headers)
322
+ end
323
+
324
+ # The error for a non-2xx response: the API's code and details, or `sdk/http-error`.
325
+ def http_error(response, limit)
326
+ text = read(response, limit).dup.force_encoding(Encoding::UTF_8)
327
+ data = begin
328
+ JSON.parse(text)
329
+ rescue JSON::ParserError, EncodingError
330
+ nil
331
+ end
234
332
  unless data.is_a?(Hash) && data['code'].is_a?(String)
235
333
  return Error.build(SdkErrorCodes::HTTP_ERROR, "HTTP #{response.status}", status: response.status,
236
334
  headers: response.headers)
237
335
  end
238
336
 
239
- details = (data['details'].is_a?(Hash) ? data['details'] : {}).merge('network' => true)
240
- details['traceId'] = data['traceId'] if data.key?('traceId')
241
- Error.build(data['code'], data['message'].is_a?(String) ? data['message'] : nil,
242
- status: response.status, details: details, headers: response.headers)
337
+ # The X-Request-Id header, or the body's `requestId` when a proxy dropped the header.
338
+ request_id = response.headers['x-request-id'] || (data['requestId'] if data['requestId'].is_a?(String))
339
+ message = data['message'].is_a?(String) ? data['message'] : nil
340
+ details = data['details'].is_a?(Hash) ? data['details'] : nil
341
+ Error.build(data['code'], message, status: response.status, details: details, headers: response.headers,
342
+ request_id: request_id)
343
+ rescue InvalidResponseError => e
344
+ e
243
345
  end
244
346
  end
245
347
  private_constant :Session
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Generated by packages/sdk-contract/mockClassification.ts from packages/shared/src/ModerationCategories.ts and ModerationPolicyDimensions.ts; do not edit.
4
+ module CalmLens
5
+ module MockClassification
6
+ # The parent categories, in the API's order.
7
+ CATEGORIES = ["sexual", "violence", "self-harm", "harassment", "hate", "criminal-activity", "personal-information", "profanity"].freeze
8
+
9
+ # Each category's qualifiers.
10
+ QUALIFIERS = {
11
+ "sexual" => ["sexual-pornographic-media", "sexual-nudity", "sexual-activity", "sexual-explicit-language", "sexual-assault-media", "sexual-non-consensual", "sexual-intimate-image-abuse", "sexual-extreme", "sexual-minors", "sexual-solicitation", "sexual-paid", "sexual-adult-promotion", "sexual-revealing-clothing", "sexual-suggestive", "sexual-non-erotic-nudity", "sexual-text", "sexual-historical", "sexual-documentary", "sexual-educational", "sexual-discussion", "sexual-fictional", "sexual-support-prevention"].freeze,
12
+ "violence" => ["violence-high-impact", "violence-attack", "violence-threat", "violence-preparation", "violence-encouragement", "violence-wish", "violence-coded-incitement", "violence-extremism", "violence-injury", "violence-violent-death", "violence-natural-death", "violence-graphic", "violence-decapitation-act", "violence-gore", "violence-weapon", "violence-sport", "violence-text", "violence-historical", "violence-documentary", "violence-educational", "violence-discussion", "violence-fictional", "violence-fantasy", "violence-support-prevention"].freeze,
13
+ "self-harm" => ["self-harm-age-restricted", "self-harm-thoughts", "self-harm-preparation", "self-harm-act", "self-harm-methods", "self-harm-encouragement", "self-harm-historical", "self-harm-documentary", "self-harm-educational", "self-harm-fictional", "self-harm-support-prevention"].freeze,
14
+ "harassment" => ["harassment-abuse", "harassment-mild", "harassment-bullying", "harassment-public-target", "harassment-threat", "harassment-sexual", "harassment-encouragement", "harassment-historical", "harassment-documentary", "harassment-educational", "harassment-fictional", "harassment-support-prevention"].freeze,
15
+ "hate" => ["hate-nazi-symbols-public-display", "hate-nazi-insignia-public-display", "hate-nazi-genocide-public-peace", "hate-nazi-genocide-denial-or-approval", "hate-adjudicated-nazi-crimes-denial", "hate-abuse", "hate-slurs", "hate-dehumanization", "hate-inferiority", "hate-exclusion", "hate-symbol", "hate-hostility", "hate-threat", "hate-personal", "hate-historical", "hate-documentary", "hate-educational", "hate-fictional", "hate-counterspeech", "hate-support-prevention"].freeze,
16
+ "criminal-activity" => ["criminal-activity-demonstration", "criminal-activity-preparation", "criminal-activity-request", "criminal-activity-instructions", "criminal-activity-promotion", "criminal-activity-coded-incitement", "criminal-activity-substances", "criminal-activity-grooming", "criminal-activity-csam", "criminal-activity-alcohol", "criminal-activity-tobacco", "criminal-activity-gambling", "criminal-activity-fraud", "criminal-activity-sexual-solicitation", "criminal-activity-graffiti", "criminal-activity-historical", "criminal-activity-documentary", "criminal-activity-educational", "criminal-activity-discussion", "criminal-activity-fictional", "criminal-activity-support-prevention"].freeze,
17
+ "personal-information" => ["personal-information-contact", "personal-information-email", "personal-information-phone", "personal-information-government-id", "personal-information-financial", "personal-information-location", "personal-information-identifiers", "personal-information-credentials", "personal-information-request", "personal-information-self-disclosure", "personal-information-third-party", "personal-information-historical", "personal-information-documentary", "personal-information-educational", "personal-information-fictional", "personal-information-support-prevention"].freeze,
18
+ "profanity" => ["profanity-mild", "profanity-strong", "profanity-slurs", "profanity-obfuscated", "profanity-historical", "profanity-documentary", "profanity-educational", "profanity-fictional", "profanity-support-prevention"].freeze,
19
+ }.freeze
20
+ end
21
+ end