tungsten-runtime 0.1.4 → 0.1.6

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.
@@ -18,6 +18,10 @@ module Tungsten
18
18
 
19
19
  # Generated clients require this capability when redirects are forbidden.
20
20
  def self.require_redirect_policy_v1; end
21
+
22
+ # Generated clients require this capability when an operation carries
23
+ # an imported-operation policy.
24
+ def self.require_import_policy_v1; end
21
25
  DEFAULT_POLL_INTERVAL_MS = 1000
22
26
  DEFAULT_VERIFY_BUDGET_MS = 30_000
23
27
  RETRYABLE_CATEGORIES = %w[RATE_LIMITED UPSTREAM_UNAVAILABLE TRANSPORT_FAILED OUTCOME_UNKNOWN].freeze
@@ -29,6 +33,10 @@ module Tungsten
29
33
  attr_reader :descriptors, :base_url, :timeout_ms, :max_event_bytes, :max_collect_bytes, :max_collect_ms,
30
34
  :max_reconnects, :reconnect_max_ms, :idle_timeout_ms
31
35
 
36
+ # Default Object inspection would include configured credentials/keys.
37
+ def inspect = "#<Tungsten::Runtime::ClientCore #{REDACTED}>"
38
+ alias_method :to_s, :inspect
39
+
32
40
  # @param descriptors [Descriptors] the SDK descriptor document
33
41
  # @param options [ClientOptions, Hash, nil]
34
42
  def initialize(descriptors, options = nil)
@@ -49,11 +57,14 @@ module Tungsten
49
57
  end
50
58
 
51
59
  @base_url = base
60
+ @allow_insecure_http = o.allow_insecure_http == true
52
61
  @auth = (o.auth || {}).to_h.transform_keys(&:to_s)
53
62
  @headers = (o.headers || {}).to_h.transform_keys(&:to_s)
54
63
  @timeout_ms = positive(o.timeout_ms) || DEFAULT_TIMEOUT_MS
64
+ @deadline_ms = positive(o.deadline_ms)
55
65
  @max_event_bytes = positive(o.max_event_bytes) || DEFAULT_MAX_EVENT_BYTES
56
- @max_response_bytes = o.max_response_bytes
66
+ @max_response_bytes = o.max_response_bytes.nil? ? 4 * 1024 * 1024 : o.max_response_bytes
67
+ @max_oauth_token_response_bytes = o.max_oauth_token_response_bytes.nil? ? 64 * 1024 : o.max_oauth_token_response_bytes
57
68
  @max_collect_bytes = positive(o.max_collect_bytes) || DEFAULT_MAX_COLLECT_BYTES
58
69
  @max_collect_ms = positive(o.max_collect_ms) || DEFAULT_MAX_COLLECT_MS
59
70
  @max_reconnects = o.max_reconnects.nil? ? DEFAULT_MAX_RECONNECTS : o.max_reconnects.to_i.clamp(0, 1000)
@@ -64,7 +75,12 @@ module Tungsten
64
75
  @middleware = Array(o.middleware)
65
76
  @validate_responses = %w[warn strict off].include?(o.validate_responses.to_s) ? o.validate_responses.to_s : "warn"
66
77
  @on_diagnostic = o.on_diagnostic
67
- @confirmation_key = o.confirmation_key.to_s.empty? ? SecureRandom.random_bytes(32) : o.confirmation_key.to_s.b
78
+ key = o.confirmation_key
79
+ copied_key = String.new(key, encoding: Encoding::BINARY) if key.is_a?(String)
80
+ if !key.nil? && (copied_key.nil? || copied_key.bytesize < 32)
81
+ raise ConfigError, "confirmation_key must contain at least 32 bytes; omit it to generate a random key"
82
+ end
83
+ @confirmation_key = copied_key || SecureRandom.random_bytes(32)
68
84
  @now_fn = o.now
69
85
  @random_fn = o.random
70
86
  @token_store = o.token_store || MemoryTokenStore.new
@@ -75,6 +91,7 @@ module Tungsten
75
91
  @used_tokens = {}
76
92
  @oauth_gates = {}
77
93
  @registry = descriptors.operations.dup
94
+ @oauth_sessions = OAuthSessions.new(self)
78
95
  end
79
96
 
80
97
  # The API descriptor (the document's `api`).
@@ -144,7 +161,51 @@ module Tungsten
144
161
  return Runtime.diag(d.id, "VALIDATION_FAILED").param("opts").expected("call options")
145
162
  .remedy("Pass the call options as a Hash of idempotency_key, confirm, timeout_ms, verify and headers (#{e.message}).").err
146
163
  end
147
- yield d, o
164
+ yield d, with_call_deadline(o)
165
+ end
166
+
167
+ # `until_deadline` gave up waiting.
168
+ DEADLINE_EXPIRED = Object.new.freeze
169
+
170
+ # The options with the absolute deadline of one logical call, fixed
171
+ # when a public entry point starts it. Nested work (pages, polls,
172
+ # verification, macro steps) inherits it and takes the earlier one, so
173
+ # it is never restarted or extended.
174
+ def with_call_deadline(opts)
175
+ ms = opts.deadline_ms.is_a?(Numeric) && opts.deadline_ms.positive? ? opts.deadline_ms : @deadline_ms
176
+ return opts unless ms
177
+
178
+ at = monotonic_ms + ms
179
+ opts.with(call_deadline: opts.call_deadline ? [opts.call_deadline, at].min : at)
180
+ end
181
+
182
+ # Milliseconds left before the call's deadline, nil without one.
183
+ def deadline_left(opts) = opts.call_deadline && (opts.call_deadline - monotonic_ms)
184
+
185
+ def deadline_passed?(opts)
186
+ left = deadline_left(opts)
187
+ !left.nil? && left <= 0
188
+ end
189
+
190
+ def deadline_expired(op, during)
191
+ Runtime.diag(op.id, "TRANSPORT_FAILED").code("DEADLINE_EXCEEDED").retrying("never")
192
+ .remedy("The operation deadline (deadline_ms) expired #{during}, so the request was not sent. Call again with a larger deadline_ms.").err
193
+ end
194
+
195
+ # The block's value, waited for only until the call's deadline
196
+ # (DEADLINE_EXPIRED after it). The work runs on its own thread and is
197
+ # not cancelled: a shared token refresh or store write keeps running
198
+ # for its other waiters, bounded by its own timeout.
199
+ def until_deadline(opts, &work)
200
+ left = deadline_left(opts)
201
+ return work.call if left.nil?
202
+ return DEADLINE_EXPIRED if left <= 0
203
+
204
+ worker = Thread.new do
205
+ Thread.current.report_on_exception = false
206
+ work.call
207
+ end
208
+ worker.join(left / 1000.0) ? worker.value : DEADLINE_EXPIRED
148
209
  end
149
210
 
150
211
  def random
@@ -231,8 +292,8 @@ module Tungsten
231
292
  current = p.headers.get("Authorization")
232
293
  return rejected unless current&.start_with?("Bearer ")
233
294
 
234
- token = refresh_after_rejection(scheme, current.delete_prefix("Bearer "))
235
- return rejected unless token
295
+ token = until_deadline(opts) { refresh_after_rejection(scheme, current.delete_prefix("Bearer ")) }
296
+ return rejected if DEADLINE_EXPIRED.equal?(token) || !token
236
297
 
237
298
  p.headers.set("Authorization", "Bearer #{token}", true)
238
299
  p.secrets << token
@@ -261,7 +322,10 @@ module Tungsten
261
322
  if op.request
262
323
  issues =
263
324
  begin
264
- op.request.validate(args)
325
+ notes = []
326
+ op.request.validate(args, notes).tap do
327
+ report_unchecked(op.id, "VALIDATION_FAILED", notes) { |path| Helpers.argument_path(op, path) }
328
+ end
265
329
  rescue StandardError
266
330
  return Runtime.diag(op.id, "UNEXPECTED_RESPONSE").retrying("never")
267
331
  .remedy("The SDK failed while building or sending the request (the request validator failed). This is a bug in the generated SDK or the runtime; report it. Nothing was sent.").err
@@ -401,12 +465,26 @@ module Tungsten
401
465
  .remedy("The operation descriptor is invalid: #{problem}. Regenerate the SDK or fix the descriptor; nothing was sent.").err
402
466
  end
403
467
 
468
+ def response_limit(op, opts)
469
+ cap = opts.max_response_bytes.nil? ? @max_response_bytes : opts.max_response_bytes
470
+ unless cap.is_a?(Integer) && cap.between?(1, 9_007_199_254_740_991)
471
+ return Runtime.diag(op.id, "VALIDATION_FAILED").param("maxResponseBytes")
472
+ .remedy("maxResponseBytes must be a positive safe integer; nothing was sent.").err
473
+ end
474
+ cap
475
+ end
476
+
404
477
  # Validate, authenticate and render a request. `purpose` is :call,
405
478
  # :preview, :server_preview or :macro_preview.
406
479
  def prepare(op, raw_args, opts, purpose, url_override, claim)
407
480
  if (err = descriptor_err(op))
408
481
  return err
409
482
  end
483
+ limit = response_limit(op, opts)
484
+ return limit if limit.is_a?(Err)
485
+ if (err = import_scope_err(op, url_override || @base_url))
486
+ return err
487
+ end
410
488
 
411
489
  register(op)
412
490
  args = normalize_args(op, raw_args)
@@ -427,7 +505,10 @@ module Tungsten
427
505
  return err if err
428
506
  end
429
507
  method = op.http_method
430
- auth = resolve_auth(op, method)
508
+ return deadline_expired(op, "before the request could be sent") if deadline_passed?(opts)
509
+
510
+ auth = until_deadline(opts) { resolve_auth(op, method, insecure_http?(url_override || @base_url) ? INSECURE_HTTP_AUTH : nil) }
511
+ return deadline_expired(op, "while authenticating") if DEADLINE_EXPIRED.equal?(auth)
431
512
  return Runtime.diag(op.id, "AUTH_FAILED").remedy(auth.failed).err if auth.failed
432
513
 
433
514
  secrets = auth.secrets
@@ -442,10 +523,14 @@ module Tungsten
442
523
  full_url, display_url = urls
443
524
  headers = HeaderBag.new
444
525
  build_headers(op, args, opts, auth.plan, headers)
526
+ if insecure_http?(full_url) && (!auth.plan.headers.empty? || !auth.plan.cookies.empty? || !auth.plan.query.empty? ||
527
+ headers.entries.any? { |h| h.secret || credential_header?(h.name) })
528
+ return Runtime.diag(op.id, "AUTH_FAILED").remedy(INSECURE_HTTP_AUTH).err
529
+ end
445
530
  param_names = op.argument_params.map(&:name)
446
531
  body_paths = Helpers.sensitive_body_paths(op)
447
532
  value = UNSET
448
- value = Serialize.body_value(op, args, param_names) if !safe_method?(method) || method == "OPTIONS"
533
+ value = Serialize.body_value(op, args, param_names) if !safe_method?(method) || %w[OPTIONS QUERY].include?(method)
449
534
  redact = lambda do |display|
450
535
  next REDACTED if body_paths.include?("")
451
536
 
@@ -458,7 +543,8 @@ module Tungsten
458
543
  end
459
544
  headers.set("Content-Type", encoded.content_type, false) if encoded.content_type
460
545
  header = Keys.header(op)
461
- key = idempotency_key(op, args, opts, purpose, encoded)
546
+ key = until_deadline(opts) { idempotency_key(op, args, opts, purpose, encoded) }
547
+ return deadline_expired(op, "while reserving an idempotency key") if DEADLINE_EXPIRED.equal?(key)
462
548
  return key if key.is_a?(Err)
463
549
 
464
550
  policy = op.agent["idempotency"]["policy"]
@@ -472,6 +558,12 @@ module Tungsten
472
558
  if purpose == :server_preview && op.agent["preview"]["mode"] == "header"
473
559
  headers.set(op.agent["preview"]["header"], op.agent["preview"]["value"], false)
474
560
  end
561
+ if insecure_http?(full_url) && headers.entries.any? { |h|
562
+ preserved = key && h.name.casecmp?(header) && h.value == key
563
+ (h.secret && !preserved) || credential_header?(h.name, preserved)
564
+ }
565
+ return Runtime.diag(op.id, "AUTH_FAILED").remedy(INSECURE_HTTP_AUTH).err
566
+ end
475
567
  headers.entries.each do |e|
476
568
  next if Serialize.valid_header_name?(e.name) && Serialize.valid_header_value?(e.value)
477
569
 
@@ -627,10 +719,39 @@ module Tungsten
627
719
  display = root + displayed + join.call(shown)
628
720
  end
629
721
  return bad_base(op) unless valid_base_url?(full)
722
+ if (err = import_scope_err(op, full))
723
+ return err
724
+ end
630
725
 
631
726
  [full, display]
632
727
  end
633
728
 
729
+ # The origin (scheme, host and port) of an absolute http(s) URL without
730
+ # credentials, or nil.
731
+ def origin_of(url)
732
+ return nil unless url.is_a?(String) && valid_base_url?(url)
733
+
734
+ u = URI.parse(url)
735
+ "#{u.scheme.downcase}://#{u.host.downcase}:#{u.port}"
736
+ rescue URI::Error
737
+ nil
738
+ end
739
+
740
+ # Refuses a reviewed imported operation whose target lies outside the
741
+ # origins its review covers. A malformed or missing scope fails closed.
742
+ def import_scope_err(op, target)
743
+ policy = op.agent["import_policy"]
744
+ return nil unless policy.is_a?(Hash) && policy["reviewed"] == true
745
+
746
+ actual = origin_of(target)
747
+ origins = policy["allowed_origins"]
748
+ allowed = !actual.nil? && origins.is_a?(Array) && origins.any? { |o| origin_of(o) == actual }
749
+ return nil if allowed
750
+
751
+ Runtime.diag(op.id, "VALIDATION_FAILED").param("base_url").retrying("never")
752
+ .remedy("The target origin is outside this operation's saved review policy. Select a reviewed server or review a separate policy for this environment. Nothing was sent.").err
753
+ end
754
+
634
755
  def query_pairs(raw)
635
756
  return [] if raw.nil? || raw.empty?
636
757
 
@@ -782,11 +903,8 @@ module Tungsten
782
903
  # returned unread as [:stream, meta, body]; otherwise Ok or Err.
783
904
  def send_with(p, opts, stream)
784
905
  op = p.op
785
- cap = opts.max_response_bytes.nil? ? @max_response_bytes : opts.max_response_bytes
786
- unless cap.nil? || (cap.is_a?(Integer) && cap.between?(1, 9_007_199_254_740_991))
787
- return Runtime.diag(op.id, "VALIDATION_FAILED").param("maxResponseBytes")
788
- .remedy("maxResponseBytes must be a positive safe integer; nothing was sent.").err
789
- end
906
+ cap = response_limit(op, opts)
907
+ return cap if cap.is_a?(Err)
790
908
  retries = retry_options(op)
791
909
  mutation = op.mutation?
792
910
  protected_ = Classify.replay_protection?(op, !p.key.nil?)
@@ -795,6 +913,12 @@ module Tungsten
795
913
  attempts = 0
796
914
  loop do
797
915
  attempts += 1
916
+ return deadline_expired(op, "before the request could be sent") if deadline_passed?(opts)
917
+
918
+ # Each attempt's timeout is cut to the time left before the call's deadline.
919
+ left = deadline_left(opts)
920
+ by_deadline = !left.nil? && left < timeout
921
+ attempt_ms = by_deadline ? [left.to_i, 1].max : timeout
798
922
  rctx = request_context(p, attempts)
799
923
  hook(:on_request, rctx)
800
924
  target = p.url
@@ -802,8 +926,9 @@ module Tungsten
802
926
  body = p.body
803
927
  headers = p.headers.entries.map { |e| [e.name, e.value] }
804
928
  send_once = lambda do
805
- Transport.attempt(url: target, method: method, headers: headers, body: body, timeout_ms: timeout, stream: stream,
806
- idle_ms: stream ? @idle_timeout_ms : nil, max_response_bytes: cap)
929
+ # An open stream keeps the idle bound of an uncut attempt; the deadline only bounds connecting.
930
+ Transport.attempt(url: target, method: method, headers: headers, body: body, timeout_ms: attempt_ms, stream: stream,
931
+ idle_ms: stream ? @idle_timeout_ms || timeout : nil, max_response_bytes: cap)
807
932
  end
808
933
  outcome = send_once.call
809
934
  unless mutation || @redirects_disabled
@@ -825,7 +950,12 @@ module Tungsten
825
950
  end
826
951
  p.final_url = target
827
952
  ctx = Classify::CallContext.new(api: @api, op: op, key: p.key, key_header: p.key_header, attempts: attempts, check: check)
828
- result = classify_response(ctx, p, outcome, rctx, timeout)
953
+ result =
954
+ if by_deadline && (outcome.kind == :timeout || (outcome.kind == :response && outcome.body_failure == "timeout" && !mutation))
955
+ deadline_timeout(ctx, outcome.kind == :response ? outcome.status : nil)
956
+ else
957
+ classify_response(ctx, p, outcome, rctx, timeout)
958
+ end
829
959
  return result unless result.is_a?(Err)
830
960
 
831
961
  d = Util.scrub_diagnostic(result.error, p.secrets)
@@ -847,11 +977,25 @@ module Tungsten
847
977
  else r * ceiling
848
978
  end
849
979
  end
980
+ # A retry that could not start before the deadline is not waited for.
981
+ left = deadline_left(opts)
982
+ return done if left && left <= delay
983
+
850
984
  hook(:on_retry, rctx, d)
851
985
  sleep([delay, 0].max / 1000.0)
852
986
  end
853
987
  end
854
988
 
989
+ # The call's deadline ran out before a response (or a read's body) arrived.
990
+ def deadline_timeout(ctx, status)
991
+ cause = "The operation deadline expired before a response arrived."
992
+ return Err.new(error: Classify.outcome_unknown(ctx, cause)) if ctx.op.mutation?
993
+
994
+ Runtime.diag(ctx.op.id, "UPSTREAM_UNAVAILABLE").code("DEADLINE_EXCEEDED").status(status).retrying("never")
995
+ .remedy("#{cause} This read has no side effects; call again with a larger deadline_ms.")
996
+ .attempts(ctx.attempts).err
997
+ end
998
+
855
999
  def classify_response(ctx, p, outcome, rctx, timeout)
856
1000
  op = p.op
857
1001
  mutation = op.mutation?
@@ -940,7 +1084,7 @@ module Tungsten
940
1084
  problem = Runtime.diag(op.id, "UNEXPECTED_RESPONSE").status(status).request_id(rid).param("response")
941
1085
  .expected(expected).remedy(remediation).attempts(attempts).build
942
1086
  elsif mode != "off" && op.response && decoded.present?
943
- issues = judge(op.response, decoded.value)
1087
+ issues = judge(op.response, decoded.value, op.id, "response")
944
1088
  unless issues.empty?
945
1089
  issue = issues.first
946
1090
  path_text = Runtime.issue_path(issue.path)
@@ -971,12 +1115,39 @@ module Tungsten
971
1115
 
972
1116
  # A validator that only judges (responses, page items, events): a
973
1117
  # failure is an issue at the root.
974
- def judge(validator, value)
975
- validator.validate(value)
1118
+ #
1119
+ # The patterns the validator could not check are reported once through
1120
+ # the observer (`PATTERN_UNCHECKED`), under `root` (for example
1121
+ # "response"); `seen` deduplicates across several calls.
1122
+ def judge(validator, value, operation, root, seen = nil)
1123
+ notes = []
1124
+ issues = validator.validate(value, notes)
1125
+ report_unchecked(operation, "UNEXPECTED_RESPONSE", notes, seen) { |path| "#{root}#{Runtime.issue_path(path)}" }
1126
+ issues
976
1127
  rescue StandardError
977
1128
  [Issue.new(path: [], message: "the response schema failed (the validator raised an error)")]
978
1129
  end
979
1130
 
1131
+ # Emits PATTERN_UNCHECKED for each schema pattern the runtime could not
1132
+ # check: the value was accepted without it. The diagnostic names the
1133
+ # path, the pattern and the reason, never the value.
1134
+ def report_unchecked(operation, category, notes, seen = nil)
1135
+ return if notes.empty? || @on_diagnostic.nil?
1136
+
1137
+ seen ||= {}
1138
+ notes.each do |n|
1139
+ param = yield(n.path)
1140
+ key = [param, n.pattern]
1141
+ next if seen.key?(key) || seen.length >= 16
1142
+
1143
+ seen[key] = true
1144
+ emit(Runtime.diag(operation, category).code("PATTERN_UNCHECKED").retrying("never").param(param)
1145
+ .expected("a value matching the pattern /#{n.pattern}/")
1146
+ .remedy("The pattern /#{n.pattern}/ at #{param} was not checked because #{n.reason}; the value was accepted without it. The server still validates it.")
1147
+ .build)
1148
+ end
1149
+ end
1150
+
980
1151
  # How to find out whether a mutation without replay protection took
981
1152
  # effect after its answer was lost.
982
1153
  def outcome_check(op, args)
@@ -1123,6 +1294,10 @@ module Tungsten
1123
1294
  return Ok.new(value: { "body" => body, "timed_out" => out_of_time }, raw: body, meta: response.meta)
1124
1295
  end
1125
1296
 
1297
+ # The next poll could not be sent before the call's deadline, so it is not waited for.
1298
+ left = deadline_left(opts)
1299
+ return deadline_expired(op, "before the next poll") if left && left <= interval_ms
1300
+
1126
1301
  sleep(interval_ms / 1000.0)
1127
1302
  end
1128
1303
  end
@@ -13,7 +13,11 @@ module Tungsten
13
13
 
14
14
  # The request body: with `arg` set the whole body is that argument;
15
15
  # otherwise a JSON object of the merged `fields` ([name, wire] pairs).
16
- BodyDescriptor = Data.define(:media_type, :encoding, :required, :fields, :arg)
16
+ # `parts` maps a multipart field's wire name to the media type its parts
17
+ # are sent with (OpenAPI `encoding`); a file part's own media type wins.
18
+ BodyDescriptor = Data.define(:media_type, :encoding, :required, :fields, :arg, :parts) do
19
+ def initialize(parts: {}, **rest) = super(parts: parts, **rest)
20
+ end
17
21
 
18
22
  # A declared response: `status` an Integer, `"4XX"` or `"default"`.
19
23
  ResponseDescriptor = Data.define(:status, :kind, :media_type)
@@ -53,7 +57,7 @@ module Tungsten
53
57
  @response = doc["response"] ? Schema.new(doc["response"], defs) : nil
54
58
  @page_item = doc["page_item"] ? Schema.new(doc["page_item"], defs) : nil
55
59
  s = doc["stream"]
56
- @stream = s ? { "event" => Schema.new(s["event"], defs), "done" => s["done"], "flag" => s["flag"] } : nil
60
+ @stream = s ? { "event" => Schema.new(s["event"], defs), "done" => s["done"], "flag" => s["flag"], "text" => s["text"] == true } : nil
57
61
  @summary = doc["summary"].to_s
58
62
  end
59
63
 
@@ -90,8 +94,13 @@ module Tungsten
90
94
  arg = (shape["name"] || shape["arg"]).to_s
91
95
  @key_names[shape["arg"].to_s] = arg
92
96
  end
97
+ parts = {}
98
+ (b["parts"] || []).each do |part|
99
+ type = part["content_type"]
100
+ parts[part["name"].to_s] ||= type.to_s.dup.freeze if type
101
+ end
93
102
  BodyDescriptor.new(media_type: b["media_type"].to_s, encoding: b["encoding"] || "json",
94
- required: b["required"] == true, fields: fields, arg: arg)
103
+ required: b["required"] == true, fields: fields, arg: arg, parts: parts.freeze)
95
104
  end
96
105
 
97
106
  def pagination_of(p)
@@ -303,6 +303,16 @@ module Tungsten
303
303
  end
304
304
 
305
305
  # The length of a text in UTF-16 code units, the unit JavaScript counts.
306
+ # The Unicode code points of a text: a surrogate pair is one, a lone
307
+ # surrogate (three bytes in a broken string) is one, and every other
308
+ # invalid byte is one.
309
+ def code_point_length(text)
310
+ return text.length if text.encoding == Encoding::UTF_8 && text.valid_encoding?
311
+
312
+ bytes = text.b.gsub(/\xED[\xA0-\xBF][\x80-\xBF]/n, "\xEF\xBF\xBD".b)
313
+ bytes.force_encoding(Encoding::UTF_8).scrub("\uFFFD").length
314
+ end
315
+
306
316
  def utf16_length(text)
307
317
  text.each_char.sum { |c| c.ord >= 0x10000 ? 2 : 1 }
308
318
  end
@@ -58,7 +58,7 @@ module Tungsten
58
58
  return Runtime.diag(m.name, "VALIDATION_FAILED").param("opts").expected("call options")
59
59
  .remedy("Pass the call options as a Hash of idempotency_key, confirm, timeout_ms, verify and headers (#{e.message}).").err
60
60
  end
61
- yield m, o
61
+ yield m, with_call_deadline(o)
62
62
  end
63
63
 
64
64
  def uses_key?(op) = %w[caller_owned auto].include?(op.agent["idempotency"]["policy"])