calmlens 0.1.1 → 0.18.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.
@@ -13,11 +13,12 @@ module CalmLens
13
13
  TRANSPORT_ERROR = 'sdk/transport-error'
14
14
  # Connecting, sending or receiving took longer than the configured timeout.
15
15
  TIMEOUT = 'sdk/timeout'
16
- # A successful response could not be read (malformed JSON or too large).
16
+ # A successful response could not be read: malformed JSON, or not what the API returns.
17
17
  INVALID_RESPONSE = 'sdk/invalid-response'
18
- # A response body was larger than the SDK reads (32 MiB for API responses).
18
+ # A response body was larger than the client's `max_response_bytes` (32 MiB by default).
19
19
  RESPONSE_TOO_LARGE = 'sdk/response-too-large'
20
- # An error response did not carry a CalmLens error code.
20
+ # An error response did not carry a CalmLens error code, or the API answered with a redirect,
21
+ # which the SDK never follows.
21
22
  HTTP_ERROR = 'sdk/http-error'
22
23
  # A webhook signature or timestamp did not verify.
23
24
  INVALID_WEBHOOK_SIGNATURE = 'sdk/invalid-webhook-signature'
@@ -31,12 +32,13 @@ module CalmLens
31
32
  # @return [String] a stable identifier such as `asset/not-found` or `sdk/timeout`.
32
33
  # Compare it with {ErrorCodes} or {SdkErrorCodes}, never with the message.
33
34
  # @!attribute [r] details
34
- # @return [Hash{String => Object}] extra API details; API errors include `"network" => true`
35
- # and, when present, `"traceId"`.
35
+ # @return [Hash{String => Object}, nil] the API's details, such as validation `issues`; nil when
36
+ # the error has no details object.
36
37
  # @!attribute [r] status
37
38
  # @return [Integer, nil] the HTTP status, or nil when no response was received.
38
39
  # @!attribute [r] request_id
39
- # @return [String, nil] the `X-Request-Id` response header. Include it when contacting support.
40
+ # @return [String, nil] the `X-Request-Id` response header, or the error body's `requestId`.
41
+ # Include it when contacting support.
40
42
  # @!attribute [r] headers
41
43
  # @return [Hash{String => String}] response headers with lowercase names (empty without a response).
42
44
  class Error < StandardError
@@ -44,7 +46,7 @@ module CalmLens
44
46
 
45
47
  # @param code [String]
46
48
  # @param message [String, nil] defaults to the code.
47
- def initialize(code, message = nil, details: {}, status: nil, headers: {}, request_id: nil)
49
+ def initialize(code, message = nil, details: nil, status: nil, headers: {}, request_id: nil)
48
50
  super("[calmlens] #{message || code}")
49
51
  @code = code
50
52
  @details = details
@@ -53,7 +55,8 @@ module CalmLens
53
55
  @request_id = request_id || headers['x-request-id']
54
56
  end
55
57
 
56
- # @return [Float, nil] seconds to wait before retrying, from the `Retry-After` header.
58
+ # @return [Integer, nil] whole seconds to wait before retrying, from the `Retry-After` header
59
+ # (seconds, or an HTTP date counted from now).
57
60
  def retry_after
58
61
  Error.parse_retry_after(headers['retry-after'])
59
62
  end
@@ -63,11 +66,19 @@ module CalmLens
63
66
  status == 404 || code.end_with?('/not-found')
64
67
  end
65
68
 
66
- # @return [Boolean] true when repeating the same request may succeed: connection failures,
67
- # timeouts, throttling (429), 502/503/504 and `idempotency/request-in-progress`.
68
- # Repeat a write only with the same idempotency key.
69
+ # Codes that may succeed when the same request is repeated, whatever the status.
70
+ RETRYABLE_CODES = [SdkErrorCodes::TRANSPORT_ERROR, SdkErrorCodes::TIMEOUT,
71
+ ErrorCodes::IDEMPOTENT_REQUEST_IN_PROGRESS].freeze
72
+ # HTTP statuses that may succeed when the same request is repeated.
73
+ RETRYABLE_STATUSES = [429, 502, 503, 504].freeze
74
+ private_constant :RETRYABLE_CODES, :RETRYABLE_STATUSES
75
+
76
+ # @return [Boolean] true when repeating the same request may succeed: connection failures
77
+ # (`sdk/transport-error`), timeouts (`sdk/timeout`, including {Assets#wait_for_decision}),
78
+ # `idempotency/request-in-progress`, throttling (429) and 502/503/504. Everything else,
79
+ # including other 5xx statuses, is false. Repeat a write only with the same idempotency key.
69
80
  def retryable?
70
- [429, 502, 503, 504].include?(status) || code == ErrorCodes::IDEMPOTENT_REQUEST_IN_PROGRESS
81
+ RETRYABLE_CODES.include?(code) || RETRYABLE_STATUSES.include?(status)
71
82
  end
72
83
 
73
84
  # Builds the most specific error class for a code and HTTP status.
@@ -88,9 +99,9 @@ module CalmLens
88
99
  return nil if value.nil? || value.to_s.strip.empty?
89
100
 
90
101
  text = value.to_s.strip
91
- return [Float(text), 0.0].max if text.match?(/\A\d+(\.\d+)?\z/)
102
+ return Integer(text, 10) if text.match?(/\A\d+\z/)
92
103
 
93
- [Time.httpdate(text) - Time.now, 0.0].max
104
+ [(Time.httpdate(text) - Time.now).ceil, 0].max
94
105
  rescue ArgumentError
95
106
  nil
96
107
  end
@@ -129,9 +140,7 @@ module CalmLens
129
140
  class ServerError < APIError; end
130
141
 
131
142
  # No complete response arrived (`sdk/transport-error`). The original exception is in `cause`.
132
- class ConnectionError < Error
133
- def retryable? = true
134
- end
143
+ class ConnectionError < Error; end
135
144
 
136
145
  # Connecting, sending or receiving exceeded its timeout, or {Assets#wait_for_decision} had no
137
146
  # decision in time (`sdk/timeout`).
@@ -3,44 +3,47 @@
3
3
  module CalmLens
4
4
  # The project's events feed. Reach it through {ServerClient#events}.
5
5
  class Events < Resource
6
- # Lists events after a cursor, oldest first. Without `after`, returns no events and a cursor
7
- # for the current time: save it, then pass each response's `cursor` as `after`.
8
- # @option options [String] :after a cursor from a previous response
9
- # @option options [Integer] :limit default 50
10
- # @return [Hash] `{'events', 'cursor', 'hasMore'}`
6
+ # Lists events after a cursor, oldest first. Without `cursor`, returns no events and a
7
+ # `nextCursor` for the current time: save it, then pass each response's `nextCursor` as `cursor`.
8
+ # @option options [String] :cursor the `nextCursor` of a previous response
9
+ # @option options [Integer] :page_size 1 to 100, default 50
10
+ # @return [Hash] `{'items', 'nextCursor', 'hasMore'}`; `nextCursor` is never nil
11
11
  def list(options = {})
12
12
  call(options) { |api, request| request('GET', project('events'), query: api, request_options: request) }
13
13
  end
14
14
 
15
- # Iterates pages from `after` until `hasMore` is false. Persist each page's `cursor` once its
16
- # events are handled, and resume from it later.
15
+ # Iterates pages from `cursor` until `hasMore` is false. Persist each page's `nextCursor` once its
16
+ # events are handled, and resume from it later. Raises `sdk/invalid-response` (with
17
+ # `details['cursor']`) instead of looping when a page with more to follow repeats a cursor.
17
18
  # @yieldparam page [Hash]
18
19
  # @return [Enumerator, nil] an Enumerator when no block is given.
19
20
  def each_page(options = {})
20
21
  return enum_for(:each_page, options) unless block_given?
21
22
 
22
23
  api, request = call(options) { |*split| split }
24
+ sent = api.key?('cursor') ? [api['cursor']] : []
23
25
  loop do
24
26
  page = list(api.merge('request_options' => request))
25
- unless page.is_a?(Hash) && page['events'].is_a?(Array) && page['cursor'].is_a?(String)
26
- raise InvalidResponseError.new(SdkErrorCodes::INVALID_RESPONSE, 'Events page has no events or cursor')
27
+ unless page.is_a?(Hash) && page['items'].is_a?(Array) && page['nextCursor'].is_a?(String)
28
+ raise InvalidResponseError.new(SdkErrorCodes::INVALID_RESPONSE, 'Events page has no items or next cursor')
27
29
  end
28
30
 
31
+ new_cursor!(sent, page['nextCursor']) if page['hasMore'] == true
29
32
  yield page
30
33
  break unless page['hasMore'] == true
31
34
 
32
- api = api.merge('after' => page['cursor'])
35
+ api = api.merge('cursor' => page['nextCursor'])
33
36
  end
34
37
  nil
35
38
  end
36
39
 
37
- # Iterates the events after `after`, across pages. See {#each_page} to track the cursor.
40
+ # Iterates the events after `cursor`, across pages. See {#each_page} to track the cursor.
38
41
  # @yieldparam event [Hash]
39
42
  # @return [Enumerator, nil] an Enumerator when no block is given.
40
43
  def each(options = {}, &block)
41
44
  return enum_for(:each, options) unless block
42
45
 
43
- each_page(options) { |page| page['events'].each(&block) }
46
+ each_page(options) { |page| page['items'].each(&block) }
44
47
  end
45
48
  end
46
49
  end
@@ -3,14 +3,21 @@
3
3
  module CalmLens
4
4
  # Folders organise a project's assets. Reach it through {ServerClient#folders}.
5
5
  class Folders < Resource
6
- # @return [Array<Hash>] every folder in the project.
6
+ # Lists one page of child folders, sorted by name unless `ordering` says otherwise.
7
+ # @option options [String] :parent_id list this folder's children; omit for the project root
8
+ # @option options [String] :cursor the previous page's `nextCursor`
9
+ # @option options [Integer] :page_size 1 to 100, default 50
10
+ # @option options [Array<Hash>] :ordering one `{field: 'name' | 'createdAt', direction: 'asc' | 'desc'}`
11
+ # @option options [String] :search only children whose name contains it
12
+ # @return [Hash] a cursor page (items, nextCursor, hasMore, pageSize, ordering) with ancestors.
7
13
  def list(options = {})
8
- call(options) do |_api, request|
9
- response = request('GET', project('folders'), request_options: request)
10
- folders = response.is_a?(Hash) ? response['folders'] : nil
11
- raise InvalidResponseError.new(SdkErrorCodes::INVALID_RESPONSE, 'Folder list has no folders') unless folders.is_a?(Array)
14
+ call(options) do |api, request|
15
+ response = request('GET', project('folders'), query: api, request_options: request)
16
+ valid = response.is_a?(Hash) && response['items'].is_a?(Array) && response['ancestors'].is_a?(Array) &&
17
+ [true, false].include?(response['hasMore'])
18
+ raise InvalidResponseError.new(SdkErrorCodes::INVALID_RESPONSE, 'Invalid folder page') unless valid
12
19
 
13
- folders
20
+ response
14
21
  end
15
22
  end
16
23
 
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CalmLens
4
+ # Hash lists hold perceptual hashes of images. Image uploads are compared with every list in the
5
+ # project: a `flag` list reports matches, a `block` list also blocks close matches. Reach it through
6
+ # {ServerClient#hash_lists}.
7
+ class HashLists < Resource
8
+ # Lists one page of the project's hash lists, newest first.
9
+ # @option options [String] :cursor the previous page's `nextCursor`
10
+ # @option options [Integer] :page_size 1 to 100, default 50
11
+ # @return [Hash] a cursor page: items, nextCursor, hasMore, pageSize and ordering.
12
+ def list(options = {})
13
+ call(options) do |api, request|
14
+ response = request('GET', project('hash-lists'), query: api, request_options: request)
15
+ valid = response.is_a?(Hash) && response['items'].is_a?(Array)
16
+ raise InvalidResponseError.new(SdkErrorCodes::INVALID_RESPONSE, 'Invalid hash list page') unless valid
17
+
18
+ response
19
+ end
20
+ end
21
+
22
+ # @option options [String] :hash_list_id
23
+ # @return [Hash] the hash list
24
+ def retrieve(options)
25
+ call(options) { |api, request| request('GET', list_path(api), request_options: request) }
26
+ end
27
+
28
+ # @option options [String] :name
29
+ # @option options [String] :action `flag` or `block`
30
+ # @option options [String, nil] :description
31
+ # @return [Hash] the hash list
32
+ def create(options)
33
+ call(options) { |api, request| request('POST', project('hash-lists'), body: api, request_options: request) }
34
+ end
35
+
36
+ # Renames a list or changes what its matches do. The new action applies to later uploads.
37
+ # @option options [String] :hash_list_id
38
+ # @option options [String] :name
39
+ # @option options [String, nil] :description
40
+ # @option options [String] :action `flag` or `block`
41
+ # @return [Hash] the hash list
42
+ def update(options)
43
+ call(options) do |api, request|
44
+ request('PATCH', list_path(api), body: without(api, 'hashListId'), request_options: request)
45
+ end
46
+ end
47
+
48
+ # Deletes the list and every entry in it. Later uploads are no longer compared with it.
49
+ # @option options [String] :hash_list_id
50
+ # @return [nil]
51
+ def delete(options)
52
+ call(options) do |api, request|
53
+ request('DELETE', list_path(api), request_options: request)
54
+ nil
55
+ end
56
+ end
57
+
58
+ # Lists one page of a list's entries, newest first.
59
+ # @option options [String] :hash_list_id
60
+ # @option options [String] :cursor the previous page's `nextCursor`
61
+ # @option options [Integer] :page_size 1 to 100, default 50
62
+ # @return [Hash] a cursor page: items, nextCursor, hasMore, pageSize and ordering.
63
+ def list_entries(options)
64
+ call(options) do |api, request|
65
+ request('GET', "#{list_path(api)}/entries", query: without(api, 'hashListId'), request_options: request)
66
+ end
67
+ end
68
+
69
+ # Adds a PDQ hash, or an image asset's hash; a hash already in the list returns its entry.
70
+ # @option options [String] :hash_list_id
71
+ # @option options [String] :hash 64 hexadecimal characters; send this or `asset_id`
72
+ # @option options [Integer] :quality
73
+ # @option options [String] :asset_id an image asset whose hash to add
74
+ # @option options [String] :label
75
+ # @return [Hash] the entry
76
+ def add_entry(options)
77
+ call(options) do |api, request|
78
+ request('POST', "#{list_path(api)}/entries", body: without(api, 'hashListId'), request_options: request)
79
+ end
80
+ end
81
+
82
+ # @option options [String] :entry_id
83
+ # @return [nil]
84
+ def remove_entry(options)
85
+ call(options) do |api, request|
86
+ request('DELETE', "/hash-list-entries/#{Internal.segment(api['entryId'])}", request_options: request)
87
+ nil
88
+ end
89
+ end
90
+
91
+ private
92
+
93
+ def list_path(api) = "/hash-lists/#{Internal.segment(api['hashListId'])}"
94
+ end
95
+ end
@@ -8,8 +8,10 @@ module CalmLens
8
8
  # @api private
9
9
  module Internal
10
10
  # Per-call settings accepted under the `request_options:` option of any method.
11
- REQUEST_OPTIONS = %i[idempotency_key timeout open_timeout max_retries].freeze
12
- IDEMPOTENCY_KEY = /\A[\x21-\x7e]{1,255}\z/
11
+ REQUEST_OPTIONS = %i[idempotency_key timeout open_timeout upload_timeout max_retries max_response_bytes].freeze
12
+ # A key the API can receive as a header value; the API checks the rest (1 to 255 printable
13
+ # ASCII characters without a space at either end) and answers `idempotency/invalid-key`.
14
+ IDEMPOTENCY_KEY = /\A[^\r\n\0]+\z/
13
15
  HEADER_VALUE = /\A[^\r\n\0]*\z/
14
16
 
15
17
  module_function
@@ -58,16 +60,19 @@ module CalmLens
58
60
  def check_request_options(request)
59
61
  key = request[:idempotency_key]
60
62
  if !key.nil? && !(key.is_a?(String) && key.match?(IDEMPOTENCY_KEY))
61
- raise invalid('idempotency_key must be 1 to 255 printable ASCII characters')
63
+ raise invalid('idempotency_key must be a non-empty String without line breaks')
62
64
  end
63
65
 
64
- %i[timeout open_timeout].each do |name|
66
+ %i[timeout open_timeout upload_timeout].each do |name|
65
67
  next if request[name].nil? || positive?(request[name])
66
68
 
67
69
  raise invalid("#{name} must be a positive number of seconds")
68
70
  end
69
71
  retries = request[:max_retries]
70
72
  raise invalid('max_retries must be a non-negative Integer') unless retries.nil? || (retries.is_a?(Integer) && retries >= 0)
73
+
74
+ limit = request[:max_response_bytes]
75
+ raise invalid('max_response_bytes must be a positive Integer') unless limit.nil? || (limit.is_a?(Integer) && limit.positive?)
71
76
  end
72
77
 
73
78
  def positive?(value)
@@ -88,11 +93,12 @@ module CalmLens
88
93
  raise invalid('Identifier is required') if text.empty?
89
94
  raise invalid('Identifier cannot be "." or ".."') if %w[. ..].include?(text)
90
95
 
91
- URI.encode_www_form_component(text).gsub('+', '%20')
96
+ # Like JavaScript's encodeURIComponent.
97
+ text.encode(Encoding::UTF_8).b.gsub(/[^A-Za-z0-9\-_.!~*'()]/n) { |byte| format('%%%02X', byte.ord) }
92
98
  end
93
99
 
94
- # Splits an absolute URL into its parsed origin and its raw request target. URI rejects the
95
- # literal brackets CDN transform selectors keep, so only the origin is parsed.
100
+ # Splits an absolute URL into its parsed origin and its raw request target. Only the origin is
101
+ # parsed, so the request target is sent exactly as the SDK built it.
96
102
  # @return [Array(URI::Generic, String)]
97
103
  def split_url(url)
98
104
  match = %r{\A([A-Za-z][A-Za-z0-9+.-]*://[^/?#]*)([^#]*)}.match(url.to_s)
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CalmLens
4
+ # Earlier posts in a conversation, sent with an upload as `context: { items: [{ text:, author_id: }] }`
5
+ # (oldest first) so CalmLens can tell who the content addresses and what it refers to. CalmLens
6
+ # moderates only the uploaded content; it uses the most recent {USED_ITEMS} posts that fit in
7
+ # {USED_CHARACTERS} characters, and the SDK sends only those.
8
+ module ModerationContext
9
+ # The most recent earlier posts CalmLens uses.
10
+ USED_ITEMS = 5
11
+ # The most characters of earlier posts CalmLens uses, counted as Unicode code points.
12
+ USED_CHARACTERS = 2_000
13
+
14
+ module_function
15
+
16
+ # The earlier posts CalmLens uses: the most recent ones, newest first, while at most
17
+ # {USED_ITEMS} posts and {USED_CHARACTERS} characters allow, returned oldest first. The newest
18
+ # post is always kept, so the API can reject one that is too long.
19
+ # @param items [Array<Hash>] posts oldest first, each with `text` and optionally `author_id`
20
+ # (Symbol or String keys, snake_case or camelCase).
21
+ # @return [Array<Hash>] the kept posts as `{'text' => String, 'authorId' => String}`, without
22
+ # `authorId` when a post has none.
23
+ # @raise [InvalidArgumentError] when `items` is not an Array of posts with String text.
24
+ def select_items(items)
25
+ raise Internal.invalid('context.items must be an array of earlier posts') unless items.is_a?(Array)
26
+
27
+ selected = []
28
+ characters = 0
29
+ items.reverse_each do |item|
30
+ break if selected.size == USED_ITEMS
31
+
32
+ post = post(item)
33
+ length = post['text'].length
34
+ break if !selected.empty? && characters + length > USED_CHARACTERS
35
+
36
+ selected.unshift(post)
37
+ characters += length
38
+ end
39
+ selected
40
+ end
41
+
42
+ # One post as the API takes it.
43
+ def post(item)
44
+ fields = item.is_a?(Hash) ? Internal.normalize(item) : nil
45
+ text = fields&.fetch('text', nil)
46
+ raise Internal.invalid('Each earlier post needs its text as a String') unless text.is_a?(String)
47
+
48
+ author = fields['authorId']
49
+ raise Internal.invalid("An earlier post's author_id must be a String") unless author.nil? || author.is_a?(String)
50
+
51
+ author.nil? ? { 'text' => text } : { 'text' => text, 'authorId' => author }
52
+ end
53
+ private_class_method :post
54
+ end
55
+ end
@@ -30,22 +30,39 @@ module CalmLens
30
30
 
31
31
  def without(options, *keys) = options.except(*keys)
32
32
 
33
- # Fetches numbered pages lazily, from `start` until `hasMore` is false.
33
+ # Fetches cursor pages lazily, from `start`, until a page has no next cursor, says there is no
34
+ # more or is empty. A page with more to follow that hands back a cursor this iteration already
35
+ # sent raises `sdk/invalid-response` rather than repeating pages forever.
34
36
  def pages(start, &fetch)
35
37
  Enumerator.new do |out|
36
- index = start
38
+ cursor = start
39
+ sent = []
37
40
  loop do
38
- page = fetch.call(index)
41
+ page = fetch.call(cursor)
39
42
  items = page.is_a?(Hash) ? page['items'] : nil
40
43
  raise InvalidResponseError.new(SdkErrorCodes::INVALID_RESPONSE, 'Page has no items') unless items.is_a?(Array)
41
44
 
45
+ following = page['nextCursor']
46
+ more = following.is_a?(String) && !following.empty? && page['hasMore'] == true && !items.empty?
47
+ new_cursor!(sent, following) if more
42
48
  out << page
43
- break unless page['hasMore'] == true && !items.empty?
49
+ break unless more
44
50
 
45
- index = page['nextCursor']
46
- break unless index
51
+ cursor = following
47
52
  end
48
53
  end
49
54
  end
55
+
56
+ # Records a cursor an iteration sends, raising `sdk/invalid-response` (with `details['cursor']`)
57
+ # when a page with more to follow hands back one it already sent, which would repeat pages forever.
58
+ def new_cursor!(sent, cursor)
59
+ if sent.include?(cursor)
60
+ raise InvalidResponseError.new(SdkErrorCodes::INVALID_RESPONSE,
61
+ 'The API returned a cursor it had already returned, with more pages to follow',
62
+ details: { 'cursor' => cursor })
63
+ end
64
+
65
+ sent << cursor
66
+ end
50
67
  end
51
68
  end
@@ -9,16 +9,31 @@ module CalmLens
9
9
  # @example
10
10
  # client = CalmLens::ServerClient.new(api_key: ENV.fetch('CALMLENS_API_KEY'),
11
11
  # project_id: ENV.fetch('CALMLENS_PROJECT_ID'))
12
- # asset = client.text.moderate(text: 'Hello world')
12
+ # asset = client.text.classify(text: 'Hello world')
13
13
  # client.close
14
14
  class ServerClient
15
15
  DEFAULT_BASE_URL = 'https://api.calmlens.com/v1'
16
16
  DEFAULT_CDN_BASE_URL = 'https://cdn.calmlens.com/v1'
17
+ # Seconds allowed for each socket read or write.
18
+ DEFAULT_TIMEOUT = 120
19
+ DEFAULT_OPEN_TIMEOUT = 10
20
+ DEFAULT_MAX_RETRIES = 2
21
+ # Seconds before the first retry; later retries wait twice as long each time, up to RETRY_MAX_DELAY.
22
+ DEFAULT_RETRY_BASE_DELAY = 0.5
23
+ RETRY_MAX_DELAY = 8
24
+ # A `Retry-After` longer than this many seconds is returned to the caller instead of waited out.
25
+ RETRY_AFTER_MAX = 60
26
+ # The largest response body read, in bytes (32 MiB).
27
+ DEFAULT_MAX_RESPONSE_BYTES = 32 * 1024 * 1024
28
+ # The most lens definitions {#ensure_lenses} sends in one request.
29
+ ENSURE_LENSES_MAX_PER_REQUEST = 100
17
30
 
18
31
  # @return [Assets]
19
32
  attr_reader :assets
20
33
  # @return [Folders]
21
34
  attr_reader :folders
35
+ # @return [HashLists]
36
+ attr_reader :hash_lists
22
37
  # @return [Text]
23
38
  attr_reader :text
24
39
  # @return [UploadTokens]
@@ -36,6 +51,11 @@ module CalmLens
36
51
  # moderation decision. It is not a deadline for a whole transfer, so large uploads and downloads
37
52
  # are not cut off. Override per call with `request_options: {timeout:}`.
38
53
  # @param open_timeout [Numeric] seconds allowed to open a connection.
54
+ # @param upload_timeout [Numeric, nil] seconds allowed for each attempt of a binary upload (file
55
+ # content or a multipart part) as a whole. Uploads have no such deadline unless you set one; text
56
+ # content follows `timeout` like other requests.
57
+ # @param max_response_bytes [Integer] the largest response body read; a larger one fails with
58
+ # `sdk/response-too-large`, before it is read when its `Content-Length` says so.
39
59
  # @param max_retries [Integer] retries of requests that are safe to repeat: GET/HEAD, and uploads,
40
60
  # which carry an `Idempotency-Key`. Connection failures, timeouts, 429, 502, 503, 504 and
41
61
  # `idempotency/request-in-progress` are retried with jittered exponential backoff, honouring
@@ -45,17 +65,22 @@ module CalmLens
45
65
  # @param transport [#call, nil] a custom {Transport}; defaults to {Transport::NetHTTP}.
46
66
  # @raise [ConfigurationError] `sdk/invalid-config` for a missing or malformed setting.
47
67
  def initialize(api_key:, project_id:, base_url: DEFAULT_BASE_URL, cdn_base_url: DEFAULT_CDN_BASE_URL,
48
- timeout: 120, open_timeout: 10, max_retries: 2, retry_base_delay: 0.5, transport: nil)
68
+ timeout: DEFAULT_TIMEOUT, open_timeout: DEFAULT_OPEN_TIMEOUT, upload_timeout: nil,
69
+ max_retries: DEFAULT_MAX_RETRIES, retry_base_delay: DEFAULT_RETRY_BASE_DELAY,
70
+ max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES, transport: nil)
49
71
  check_config(api_key, project_id, timeout, open_timeout, max_retries, retry_base_delay, transport)
72
+ check_limits(upload_timeout, max_response_bytes)
50
73
  @project_id = project_id
51
74
  @base_url = ServerClient.check_url(base_url, 'base_url')
52
75
  @cdn_base_url = ServerClient.check_url(cdn_base_url, 'cdn_base_url')
53
76
  @session = Session.new(api_key: api_key, project_id: project_id, base_url: @base_url,
54
77
  cdn_base_url: @cdn_base_url, timeout: timeout, open_timeout: open_timeout,
55
78
  max_retries: max_retries, retry_base_delay: retry_base_delay,
79
+ max_response_bytes: max_response_bytes, upload_timeout: upload_timeout,
56
80
  transport: transport || Transport::NetHTTP.new)
57
81
  @assets = Assets.new(@session)
58
82
  @folders = Folders.new(@session)
83
+ @hash_lists = HashLists.new(@session)
59
84
  @text = Text.new(@session, @assets)
60
85
  @upload_tokens = UploadTokens.new(@session, api_key)
61
86
  @events = Events.new(@session)
@@ -110,6 +135,15 @@ module CalmLens
110
135
 
111
136
  private
112
137
 
138
+ def check_limits(upload_timeout, max_response_bytes)
139
+ unless upload_timeout.nil? || Internal.positive?(upload_timeout)
140
+ raise ConfigurationError.new(SdkErrorCodes::INVALID_CONFIG, 'upload_timeout must be nil or a positive number of seconds')
141
+ end
142
+ return if max_response_bytes.is_a?(Integer) && max_response_bytes.positive?
143
+
144
+ raise ConfigurationError.new(SdkErrorCodes::INVALID_CONFIG, 'max_response_bytes must be a positive Integer')
145
+ end
146
+
113
147
  def check_config(api_key, project_id, timeout, open_timeout, max_retries, retry_base_delay, transport)
114
148
  unless api_key.is_a?(String) && !api_key.empty? && api_key.match?(/\A[\x21-\x7e]+\z/)
115
149
  raise ConfigurationError.new(SdkErrorCodes::INVALID_CONFIG, 'api_key must be a non-empty printable ASCII String')