loams 0.1.0 → 0.1.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.
data/lib/loams/client.rb CHANGED
@@ -1,25 +1,71 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
3
4
  require "loams/descriptors"
4
5
  require "loams/transport"
5
6
  require "loams/invoker"
6
7
  require "loams/pagination"
8
+ require "loams/consistency"
9
+ require "loams/app"
7
10
 
8
11
  module Loams
9
12
  class Client
10
- attr_reader :invoker, :endpoint
13
+ attr_reader :invoker, :endpoint, :namespace, :session
11
14
 
15
+ # The app modules (SDKP §2), hand-written over the descriptor set's
16
+ # bindings. They take the place of the descriptor-derived module of the
17
+ # same name, whose raw calls stay reachable through `client.module(name)`.
18
+ APP_MODULES = {
19
+ "namespaces" => App::Namespaces,
20
+ "collections" => App::Collections,
21
+ "documents" => App::Documents,
22
+ "search" => App::SearchModule,
23
+ "vector" => App::VectorModule,
24
+ "graph" => App::Graph,
25
+ "sql" => App::Sql,
26
+ "streams" => App::Streams,
27
+ }.freeze
28
+
29
+ # A client from the environment (R1): `LOAMS_ENDPOINT`, then
30
+ # `LOAMS_API_KEY` or `LOAMS_TOKEN`, then `LOAMS_NAMESPACE`. Keyword
31
+ # arguments win over the environment.
32
+ def self.from_env(env = ENV, **options)
33
+ endpoint = env["LOAMS_ENDPOINT"]
34
+ if endpoint.nil? || endpoint.empty?
35
+ raise InvalidArgumentError.new("LOAMS_ENDPOINT is not set", code: :invalid_argument)
36
+ end
37
+
38
+ key = env["LOAMS_API_KEY"]
39
+ token = env["LOAMS_TOKEN"]
40
+ defaults = {}
41
+ defaults[:api_key] = key unless key.nil? || key.empty?
42
+ defaults[:token] = token if defaults[:api_key].nil? && !(token.nil? || token.empty?)
43
+ ns = env["LOAMS_NAMESPACE"]
44
+ defaults[:namespace] = ns unless ns.nil? || ns.empty?
45
+ new(endpoint, **defaults, **options)
46
+ end
47
+
48
+ # +api_key+ is sent as `Authorization: Bearer` (R1), as are +token+ and
49
+ # +token_source+. +namespace+ is the default namespace (R11). +timeout+
50
+ # is the unary default in seconds, nil for none (R12).
51
+ # +session_consistency+ turns on the session store (R4).
12
52
  def initialize(
13
53
  endpoint = "http://127.0.0.1:4400",
14
54
  protocol: :connect,
15
55
  codec: :proto,
56
+ api_key: nil,
16
57
  token: nil,
17
58
  token_source: nil,
59
+ namespace: nil,
60
+ timeout: CallInvoker::DEFAULT_TIMEOUT,
61
+ session_consistency: false,
18
62
  transport: nil,
19
63
  max_retries: 3
20
64
  )
21
65
  @endpoint = endpoint
22
- tokens = token_source || token
66
+ @namespace = namespace&.to_s
67
+ @session = session_consistency ? SessionStore.new : nil
68
+ tokens = token_source || api_key || token
23
69
  retries = RetryPolicy.new(max_retries: max_retries)
24
70
  tp = transport || HttpTransport.new(endpoint)
25
71
 
@@ -29,6 +75,7 @@ module Loams
29
75
  codec: codec,
30
76
  token_source: tokens,
31
77
  retry_policy: retries,
78
+ timeout: timeout,
32
79
  )
33
80
 
34
81
  # Build module clients
@@ -40,12 +87,39 @@ module Loams
40
87
  method_name = Descriptors.snake(mod.name)
41
88
  define_singleton_method(method_name) { client }
42
89
  end
90
+
91
+ APP_MODULES.each do |name, klass|
92
+ app = klass.new(self)
93
+ define_singleton_method(name) { app }
94
+ end
43
95
  end
44
96
 
45
97
  def module(name)
46
98
  @modules[name.to_s]
47
99
  end
48
100
 
101
+ # Any facade call by its facade-index name (`documents.write`,
102
+ # `instance.get_instance`), over its request's proto3 JSON or message.
103
+ # `headers:`, `timeout:` and `idempotency_key:` are the call's options.
104
+ def call(name, request = {}, **opts)
105
+ mod, call = name.split(".", 2)
106
+ if App::CALLS.key?([mod, call])
107
+ (@raw ||= App::Raw.new(self)).call(name, request, **opts)
108
+ else
109
+ client = @modules[mod] or raise ArgumentError, "no module #{mod}"
110
+ binding = client.module_binding.calls.find { |b| Descriptors.snake(b.facade_name) == call }
111
+ raise ArgumentError, "no call #{name}" if binding.nil?
112
+
113
+ message = request.is_a?(Hash) ? binding.request.msgclass.decode_json(JSON.generate(request)) : request
114
+ client.public_send(call, message, opts)
115
+ end
116
+ end
117
+
118
+ # A handle on one collection (SDKP §2.8).
119
+ def collection(name, namespace: nil)
120
+ CollectionHandle.new(self, name, namespace: namespace)
121
+ end
122
+
49
123
  class ModuleClient
50
124
  attr_reader :module_binding, :invoker
51
125
 
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "loams/errors"
4
+
5
+ module Loams
6
+ # A consistency token (runtime contract R4): `v1:s<stream>/p<partition>@<offset>,…`,
7
+ # the encoding `crates/loams-collection/src/token.rs` defines.
8
+ #
9
+ # A write answers with one and a read accepts one as `at_least`, so a caller
10
+ # that just wrote can read its own write. Two tokens merge by keeping the
11
+ # largest offset per `(stream, partition)`, which is what lets a session fold
12
+ # every answer it sees into one token.
13
+ class ConsistencyToken
14
+ PREFIX = "v1:"
15
+ U32_MAX = 0xFFFF_FFFF
16
+ U64_MAX = 0xFFFF_FFFF_FFFF_FFFF
17
+
18
+ # The `(stream, partition, offset)` triples, in the order the token
19
+ # spells them. `merge` (and `normalized`) sort them, one per
20
+ # `(stream, partition)`.
21
+ attr_reader :items
22
+
23
+ # Parses +text+, raising `InvalidArgumentError` (client-side, no network
24
+ # call) when it is not a `v1:` token.
25
+ def self.parse(text)
26
+ raise invalid(text) unless text.is_a?(String) && text.start_with?(PREFIX)
27
+
28
+ body = text[PREFIX.length..]
29
+ return new([]) if body.empty?
30
+
31
+ items = body.split(",", -1).map do |item|
32
+ parse_item(item) || raise(invalid(text))
33
+ end
34
+ new(items)
35
+ end
36
+
37
+ # Whether +text+ parses.
38
+ def self.valid?(text)
39
+ parse(text)
40
+ true
41
+ rescue LoamsError
42
+ false
43
+ end
44
+
45
+ def initialize(items = [])
46
+ @items = items.map { |i| i.dup.freeze }.freeze
47
+ freeze
48
+ end
49
+
50
+ # The same token sorted by stream then partition, one item per pair (the
51
+ # largest offset).
52
+ def normalized
53
+ self.class.new(normalize(@items))
54
+ end
55
+
56
+ # The token's text, `v1:` followed by the triples.
57
+ def to_s
58
+ PREFIX + @items.map { |s, p, o| "s#{s}/p#{p}@#{o}" }.join(",")
59
+ end
60
+
61
+ # A new token holding the larger offset of the two for every
62
+ # `(stream, partition)`.
63
+ def merge(other)
64
+ other = self.class.parse(other) if other.is_a?(String)
65
+ self.class.new(normalize(@items + other.items))
66
+ end
67
+
68
+ # The offset this token holds for one partition, or nil.
69
+ def offset(stream, partition)
70
+ @items.select { |s, p, _| s == stream && p == partition }.map(&:last).max
71
+ end
72
+
73
+ def empty?
74
+ @items.empty?
75
+ end
76
+
77
+ def ==(other)
78
+ other.is_a?(ConsistencyToken) && other.items == @items
79
+ end
80
+ alias eql? ==
81
+
82
+ def hash
83
+ @items.hash
84
+ end
85
+
86
+ def inspect
87
+ "#<Loams::ConsistencyToken #{self}>"
88
+ end
89
+
90
+ def self.parse_item(item)
91
+ return nil unless item.start_with?("s")
92
+
93
+ stream, rest = item[1..].split("/p", 2)
94
+ return nil if rest.nil?
95
+
96
+ partition, offset = rest.split("@", 2)
97
+ return nil if offset.nil?
98
+
99
+ s = canonical(stream, U64_MAX)
100
+ p = canonical(partition, U32_MAX)
101
+ o = canonical(offset, U64_MAX)
102
+ return nil if s.nil? || p.nil? || o.nil?
103
+
104
+ [s, p, o]
105
+ end
106
+ private_class_method :parse_item
107
+
108
+ # A decimal with no sign, no leading zero (except `0`) and in range.
109
+ def self.canonical(text, max)
110
+ return nil unless text.match?(/\A(0|[1-9][0-9]*)\z/)
111
+
112
+ n = Integer(text, 10)
113
+ n <= max ? n : nil
114
+ end
115
+ private_class_method :canonical
116
+
117
+ def self.invalid(text)
118
+ InvalidArgumentError.new("invalid consistency token #{text.inspect}", code: :invalid_argument, reason: :invalid_consistency_token)
119
+ end
120
+ private_class_method :invalid
121
+
122
+ private
123
+
124
+ def normalize(items)
125
+ items.sort.each_with_object([]) do |item, out|
126
+ if out.last && out.last[0] == item[0] && out.last[1] == item[1]
127
+ out[-1] = item
128
+ else
129
+ out << item
130
+ end
131
+ end.freeze
132
+ end
133
+ end
134
+
135
+ # The session store (R4, `session_consistency: true`): every token an
136
+ # answer carries is merged in, and every read that leaves `consistency`
137
+ # unset is sent with `at_least` set to the session's token.
138
+ #
139
+ # One token per proto package: a graph's tokens name the graph's own
140
+ # stream and a graph read refuses a token of another stream, so the
141
+ # collection and graph sessions are kept apart.
142
+ class SessionStore
143
+ # The response fields a token is read from.
144
+ TOKEN_FIELDS = %w[token read_token consistency_token].freeze
145
+
146
+ def initialize
147
+ @tokens = {}
148
+ @lock = Mutex.new
149
+ end
150
+
151
+ # The session token for +scope+ (a proto package), or nil.
152
+ def token(scope = "loams.collection.v1")
153
+ @lock.synchronize { @tokens[scope] }
154
+ end
155
+
156
+ # Merges +text+ into the session for +scope+. A token that does not
157
+ # parse is ignored: the session never holds a token it cannot merge.
158
+ def observe(scope, text)
159
+ return if text.nil? || text.empty?
160
+ return unless ConsistencyToken.valid?(text)
161
+
162
+ parsed = ConsistencyToken.parse(text)
163
+ @lock.synchronize do
164
+ current = @tokens[scope]
165
+ @tokens[scope] = current ? current.merge(parsed) : parsed
166
+ end
167
+ end
168
+
169
+ # Reads every token field off +message+ and merges it in.
170
+ def observe_message(scope, message)
171
+ return if message.nil? || !message.respond_to?(:class) || !message.class.respond_to?(:descriptor)
172
+
173
+ TOKEN_FIELDS.each do |name|
174
+ field = message.class.descriptor.lookup(name)
175
+ next if field.nil? || field.type != :string || field.label == :repeated
176
+
177
+ observe(scope, message[name])
178
+ end
179
+ end
180
+
181
+ # Sets `consistency.at_least` on +request+ when it has a `consistency`
182
+ # field the caller left unset and the session holds a token.
183
+ def apply(scope, request)
184
+ return request unless request.respond_to?(:class) && request.class.respond_to?(:descriptor)
185
+
186
+ field = request.class.descriptor.lookup("consistency")
187
+ return request if field.nil? || field.type != :message
188
+ return request unless request[field.name].nil?
189
+
190
+ current = token(scope)
191
+ return request if current.nil? || current.empty?
192
+
193
+ sub = field.subtype.msgclass.new
194
+ return request if sub.class.descriptor.lookup("at_least").nil?
195
+
196
+ sub["at_least"] = current.to_s
197
+ request[field.name] = sub
198
+ request
199
+ end
200
+ end
201
+ end
@@ -88,6 +88,12 @@ module Loams
88
88
  collect_messages(file.message_type, file.package, "")
89
89
  end
90
90
 
91
+ # Every message's class, built up front. `google-protobuf` builds a
92
+ # class on first `msgclass`, and iterating a repeated message field whose
93
+ # element class was never built raises `TypeError` (a `SearchResponse`'s
94
+ # `hits`), so no class is left to be built lazily.
95
+ MESSAGES.each_value { |descriptor| descriptor&.msgclass }
96
+
91
97
  # Every service in the set, in the order the files declare them.
92
98
  SERVICES = SET.file.flat_map do |file|
93
99
  file.service.to_a.map do |raw_svc|
data/lib/loams/errors.rb CHANGED
@@ -54,6 +54,7 @@ module Loams
54
54
  :idempotency_key_reused,
55
55
  :workspace_not_found,
56
56
  :workspace_fenced,
57
+ :batch_partially_applied,
57
58
  :workspace_read_only,
58
59
  :workspace_name_taken,
59
60
  :object_missing,
@@ -61,17 +62,46 @@ module Loams
61
62
  :env_unsealed,
62
63
  :env_access_denied,
63
64
  :env_foreign_reference,
65
+ :memory_store_not_found,
66
+ :memory_space_not_found,
67
+ :memory_space_limit,
68
+ :memory_managed_object,
69
+ :memory_block_version_mismatch,
70
+ :memory_block_too_large,
71
+ :memory_core_budget_exceeded,
72
+ :memory_content_too_large,
73
+ :memory_secret_detected,
74
+ :memory_pii_refused,
75
+ :memory_model_not_configured,
76
+ :memory_model_not_local,
77
+ :memory_token_budget_exhausted,
78
+ :memory_extraction_failed,
79
+ :memory_too_many_spaces,
80
+ :memory_cross_space_write,
81
+ :memory_not_yet_available,
82
+ :code_repo_not_indexed,
83
+ :code_index_building,
84
+ :code_change_too_large,
85
+ :code_language_unsupported,
86
+ :code_scip_invalid,
64
87
  :live_bad_cursor,
65
88
  :live_worker_crashed,
66
89
  :live_deploy_conflict,
67
90
  :live_schema_violation,
91
+ :filter_conflict,
92
+ :search_after_needs_tiebreak,
93
+ :invalid_consistency_token,
94
+ :namespace_required,
95
+ :sql_param_count,
68
96
  :live_index_not_ready,
69
97
  :live_app_disabled,
70
98
  :live_app_exists,
71
99
  :live_function_error,
72
100
  :live_function_timeout,
101
+ :client_deadline_exceeded,
73
102
  :live_function_out_of_memory,
74
103
  :live_limit_exceeded,
104
+ :result_too_large,
75
105
  :live_app_mismatch,
76
106
  :live_origin_not_allowed,
77
107
  :session_rotate,
@@ -159,6 +189,7 @@ module Loams
159
189
  idempotency_key_reused: :failed_precondition,
160
190
  workspace_not_found: :not_found,
161
191
  workspace_fenced: :aborted,
192
+ batch_partially_applied: :aborted,
162
193
  workspace_read_only: :failed_precondition,
163
194
  workspace_name_taken: :already_exists,
164
195
  object_missing: :failed_precondition,
@@ -166,17 +197,46 @@ module Loams
166
197
  env_unsealed: :invalid_argument,
167
198
  env_access_denied: :permission_denied,
168
199
  env_foreign_reference: :permission_denied,
200
+ memory_store_not_found: :not_found,
201
+ memory_space_not_found: :not_found,
202
+ memory_space_limit: :resource_exhausted,
203
+ memory_managed_object: :failed_precondition,
204
+ memory_block_version_mismatch: :aborted,
205
+ memory_block_too_large: :invalid_argument,
206
+ memory_core_budget_exceeded: :resource_exhausted,
207
+ memory_content_too_large: :invalid_argument,
208
+ memory_secret_detected: :failed_precondition,
209
+ memory_pii_refused: :failed_precondition,
210
+ memory_model_not_configured: :failed_precondition,
211
+ memory_model_not_local: :failed_precondition,
212
+ memory_token_budget_exhausted: :resource_exhausted,
213
+ memory_extraction_failed: :failed_precondition,
214
+ memory_too_many_spaces: :invalid_argument,
215
+ memory_cross_space_write: :invalid_argument,
216
+ memory_not_yet_available: :unimplemented,
217
+ code_repo_not_indexed: :failed_precondition,
218
+ code_index_building: :unavailable,
219
+ code_change_too_large: :invalid_argument,
220
+ code_language_unsupported: :failed_precondition,
221
+ code_scip_invalid: :invalid_argument,
169
222
  live_bad_cursor: :invalid_argument,
170
223
  live_worker_crashed: :internal,
171
224
  live_deploy_conflict: :failed_precondition,
172
225
  live_schema_violation: :invalid_argument,
226
+ filter_conflict: :invalid_argument,
227
+ search_after_needs_tiebreak: :invalid_argument,
228
+ invalid_consistency_token: :invalid_argument,
229
+ namespace_required: :invalid_argument,
230
+ sql_param_count: :invalid_argument,
173
231
  live_index_not_ready: :failed_precondition,
174
232
  live_app_disabled: :failed_precondition,
175
233
  live_app_exists: :failed_precondition,
176
234
  live_function_error: :unknown,
177
235
  live_function_timeout: :deadline_exceeded,
236
+ client_deadline_exceeded: :deadline_exceeded,
178
237
  live_function_out_of_memory: :resource_exhausted,
179
238
  live_limit_exceeded: :resource_exhausted,
239
+ result_too_large: :resource_exhausted,
180
240
  live_app_mismatch: :permission_denied,
181
241
  live_origin_not_allowed: :permission_denied,
182
242
  session_rotate: :unavailable,
data/lib/loams/invoker.rb CHANGED
@@ -7,18 +7,25 @@ require "loams/idempotency"
7
7
  require "loams/retry"
8
8
  require "loams/token_source"
9
9
  require "loams/streams"
10
+ require "net/http"
11
+ require "timeout"
10
12
 
11
13
  module Loams
12
14
  class CallInvoker
13
- attr_reader :transport, :protocol, :codec, :token_source, :retry_policy
15
+ # The client default for a unary call, in seconds (R12). Streams have none.
16
+ DEFAULT_TIMEOUT = 30
17
+
18
+ attr_reader :transport, :protocol, :codec, :token_source, :retry_policy, :timeout
14
19
 
15
20
  def initialize(
16
21
  transport:,
17
22
  protocol: :connect,
18
23
  codec: :proto,
19
24
  token_source: nil,
20
- retry_policy: nil
25
+ retry_policy: nil,
26
+ timeout: DEFAULT_TIMEOUT
21
27
  )
28
+ @timeout = timeout
22
29
  @transport = transport
23
30
  @protocol = protocol
24
31
  @codec = codec
@@ -33,9 +40,15 @@ module Loams
33
40
 
34
41
  attempt = 0
35
42
  refreshed_token = false
43
+ # R12: one budget for the call, retries included. `timeout: nil` on the
44
+ # call or the client means no deadline.
45
+ budget = options.key?(:timeout) ? options[:timeout] : @timeout
46
+ deadline = budget && (monotonic + budget.to_f)
36
47
 
37
48
  loop do
38
49
  attempt += 1
50
+ remaining = deadline && (deadline - monotonic)
51
+ raise deadline_error(binding.rpc) if remaining && remaining <= 0
39
52
 
40
53
  # Build headers
41
54
  ct = ContentTypes.content_type_for(@protocol, @codec, streaming: false)
@@ -49,6 +62,15 @@ module Loams
49
62
  headers["idempotency-key"] = key
50
63
  end
51
64
 
65
+ if remaining
66
+ ms = [(remaining * 1000).ceil, 1].max
67
+ if @protocol == :grpc_web
68
+ headers["grpc-timeout"] = "#{ms}m"
69
+ else
70
+ headers["connect-timeout-ms"] = ms.to_s
71
+ end
72
+ end
73
+
52
74
  if options[:headers]
53
75
  options[:headers].each { |k, v| headers[k.to_s.downcase] = v.to_s }
54
76
  end
@@ -65,10 +87,14 @@ module Loams
65
87
  headers: headers,
66
88
  body: body,
67
89
  rpc: binding.rpc,
90
+ timeout: remaining,
68
91
  )
69
92
 
70
93
  res = begin
71
94
  @transport.call_unary(req)
95
+ rescue Net::OpenTimeout, Net::ReadTimeout, Timeout::Error
96
+ # The budget is spent, so this is never retried.
97
+ raise deadline_error(binding.rpc)
72
98
  rescue StandardError => e
73
99
  raise ErrorParser.build_error(:unknown, e.message, nil, nil, binding.rpc)
74
100
  end
@@ -192,6 +218,19 @@ module Loams
192
218
 
193
219
  private
194
220
 
221
+ def monotonic
222
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
223
+ end
224
+
225
+ def deadline_error(rpc)
226
+ DeadlineExceededError.new(
227
+ "the call ran out of time on the client",
228
+ code: :deadline_exceeded,
229
+ reason: :client_deadline_exceeded,
230
+ rpc: rpc,
231
+ )
232
+ end
233
+
195
234
  def current_token(force_refresh: false)
196
235
  @token_source&.token(force_refresh: force_refresh)
197
236
  end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Loams
4
+ # R14: `streams.subscribe`, a loop over unary `Fetch` (SDKP-2), so it works
5
+ # on Connect-unary and through any proxy.
6
+ #
7
+ # The next offset is the last record's offset + 1. An empty fetch backs off
8
+ # 100 ms, doubling up to 1 s, and a non-empty one resets the backoff.
9
+ # `offset` is where the next fetch starts, so a caller can resume from it;
10
+ # `cancel` stops the loop at the next fetch boundary.
11
+ #
12
+ # sub = client.streams.subscribe("events", 0, from_offset: 1)
13
+ # sub.each { |record| … ; sub.cancel if done }
14
+ class Subscription
15
+ include Enumerable
16
+
17
+ INITIAL_BACKOFF = 0.1
18
+ MAX_BACKOFF = 1.0
19
+
20
+ attr_reader :offset
21
+
22
+ # +fetch+ is called with an offset and answers the records at and after
23
+ # it, each responding to `offset` (or a Hash with `"offset"`).
24
+ def initialize(from_offset: 0, sleeper: nil, &fetch)
25
+ raise ArgumentError, "a subscription needs a fetch block" unless fetch
26
+
27
+ @offset = Integer(from_offset)
28
+ @fetch = fetch
29
+ @sleeper = sleeper || ->(seconds) { sleep(seconds) }
30
+ @cancelled = false
31
+ end
32
+
33
+ def cancel
34
+ @cancelled = true
35
+ end
36
+
37
+ def cancelled?
38
+ @cancelled
39
+ end
40
+
41
+ def each
42
+ return to_enum(:each) unless block_given?
43
+
44
+ backoff = INITIAL_BACKOFF
45
+ until @cancelled
46
+ records = Array(@fetch.call(@offset))
47
+ if records.empty?
48
+ @sleeper.call(backoff)
49
+ backoff = [backoff * 2, MAX_BACKOFF].min
50
+ next
51
+ end
52
+ backoff = INITIAL_BACKOFF
53
+ records.each do |record|
54
+ break if @cancelled
55
+
56
+ @offset = Integer(record_offset(record)) + 1
57
+ yield record
58
+ end
59
+ end
60
+ self
61
+ end
62
+
63
+ private
64
+
65
+ def record_offset(record)
66
+ if record.respond_to?(:offset)
67
+ record.offset
68
+ elsif record.is_a?(Hash)
69
+ record["offset"] || record[:offset]
70
+ else
71
+ raise ArgumentError, "a fetched record has no offset"
72
+ end
73
+ end
74
+ end
75
+ end
@@ -4,7 +4,7 @@ require "net/http"
4
4
  require "uri"
5
5
 
6
6
  module Loams
7
- Request = Struct.new(:method, :path, :headers, :body, :rpc, keyword_init: true)
7
+ Request = Struct.new(:method, :path, :headers, :body, :rpc, :timeout, keyword_init: true)
8
8
  Response = Struct.new(:status, :headers, :body, :stream, keyword_init: true)
9
9
 
10
10
  class HttpTransport
@@ -22,8 +22,10 @@ module Loams
22
22
  url = URI.join(@endpoint + "/", request.path.sub(%r{^/+}, ""))
23
23
  http = Net::HTTP.new(url.host, url.port)
24
24
  http.use_ssl = (url.scheme == "https")
25
- http.read_timeout = 30
26
- http.open_timeout = 5
25
+ # R12: the call's remaining budget, or no limit when it has none.
26
+ http.read_timeout = request.timeout
27
+ http.open_timeout = request.timeout ? [request.timeout, 5].min : 5
28
+ http.write_timeout = request.timeout if request.timeout
27
29
 
28
30
  req = Net::HTTPGenericRequest.new(
29
31
  request.method || "POST",