typesafe-ai-ruby 0.1.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.
Files changed (38) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +21 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +176 -0
  5. data/lib/typesafe/client.rb +56 -0
  6. data/lib/typesafe/configuration.rb +129 -0
  7. data/lib/typesafe/constants.rb +49 -0
  8. data/lib/typesafe/errors.rb +117 -0
  9. data/lib/typesafe/http/connection_manager.rb +82 -0
  10. data/lib/typesafe/http/net_http_transport.rb +74 -0
  11. data/lib/typesafe/http/request.rb +30 -0
  12. data/lib/typesafe/http/requestor.rb +153 -0
  13. data/lib/typesafe/http/response.rb +48 -0
  14. data/lib/typesafe/http/retrier.rb +57 -0
  15. data/lib/typesafe/instrumentation.rb +84 -0
  16. data/lib/typesafe/logging.rb +37 -0
  17. data/lib/typesafe/questions/choice.rb +33 -0
  18. data/lib/typesafe/questions/normalizer.rb +65 -0
  19. data/lib/typesafe/questions/noul.rb +36 -0
  20. data/lib/typesafe/questions/question.rb +48 -0
  21. data/lib/typesafe/questions/score.rb +32 -0
  22. data/lib/typesafe/request_options.rb +56 -0
  23. data/lib/typesafe/resources/models.rb +18 -0
  24. data/lib/typesafe/responses/answer.rb +143 -0
  25. data/lib/typesafe/responses/list_models_response.rb +93 -0
  26. data/lib/typesafe/responses/reader.rb +75 -0
  27. data/lib/typesafe/responses/system_one_response.rb +89 -0
  28. data/lib/typesafe/responses/usage.rb +37 -0
  29. data/lib/typesafe/retry_policy.rb +133 -0
  30. data/lib/typesafe/util.rb +156 -0
  31. data/lib/typesafe/version.rb +5 -0
  32. data/lib/typesafe-ai-ruby.rb +4 -0
  33. data/lib/typesafe.rb +114 -0
  34. data/sig/typesafe/http.rbs +77 -0
  35. data/sig/typesafe/questions.rbs +48 -0
  36. data/sig/typesafe/responses.rbs +131 -0
  37. data/sig/typesafe.rbs +261 -0
  38. metadata +97 -0
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypeSafe
4
+ module Questions
5
+ # Rates the state against ordered levels. Each level's index is its score, starting at zero.
6
+ #
7
+ # For example:
8
+ # TypeSafe::Score.new(
9
+ # instructions: "How urgent is this?",
10
+ # criteria: ["Can wait", "Needs attention this week", "Needs attention today"]
11
+ # )
12
+ class Score < Question
13
+ TYPE = "score"
14
+ MIN_LEVELS = 2
15
+
16
+ def initialize(criteria:, instructions: nil)
17
+ super(TYPE, instructions: instructions, criteria: self.class.normalize_criteria(criteria))
18
+ end
19
+
20
+ def self.normalize_criteria(criteria)
21
+ raise ValidationError, "Score criteria must be an Array of at least #{MIN_LEVELS} levels" unless
22
+ criteria.is_a?(Array) && criteria.length >= MIN_LEVELS
23
+
24
+ criteria.map do |level|
25
+ raise ValidationError, "Score levels must not be nil" if level.nil?
26
+
27
+ Util.deep_stringify(level)
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypeSafe
4
+ # Per-call overrides accepted by Client#system_one and Resources::Models#list.
5
+ class RequestOptions
6
+ KEYS = %i[timeout headers extra_body retry_policy].freeze
7
+
8
+ # Timeout per attempt in seconds for this call only.
9
+ attr_reader :timeout
10
+ # Additional headers for this call only.
11
+ attr_reader :headers
12
+ # Extra top-level body fields, shallow-merged last-write-wins.
13
+ attr_reader :extra_body
14
+ # Retry policy override for this call only: a RetryPolicy, or a Hash applied on the client policy.
15
+ attr_reader :retry_policy
16
+
17
+ def self.from(options)
18
+ case options
19
+ when RequestOptions then options
20
+ when nil then new
21
+ when Hash then new(**options.transform_keys(&:to_sym))
22
+ else raise ArgumentError, "request_options must be a Hash, got #{options.class}"
23
+ end
24
+ end
25
+
26
+ def initialize(timeout: nil, headers: nil, extra_body: nil, retry_policy: nil)
27
+ @timeout = validate_timeout(timeout)
28
+ @headers = (headers || {}).each_with_object({}) { |(name, value), result| result[name.to_s] = value.to_s }
29
+ @extra_body = validate_extra_body(extra_body)
30
+ @retry_policy = validate_retry_policy(retry_policy)
31
+ freeze
32
+ end
33
+
34
+ private
35
+
36
+ def validate_timeout(timeout)
37
+ return nil if timeout.nil?
38
+ return timeout if timeout.is_a?(Numeric) && timeout.finite? && timeout.positive?
39
+
40
+ raise ArgumentError, "timeout must be a positive number of seconds, got #{timeout.inspect}"
41
+ end
42
+
43
+ def validate_retry_policy(retry_policy)
44
+ return retry_policy if retry_policy.nil? || retry_policy.is_a?(RetryPolicy) || retry_policy.is_a?(Hash)
45
+
46
+ raise ArgumentError, "retry_policy must be a RetryPolicy or a Hash, got #{retry_policy.class}"
47
+ end
48
+
49
+ def validate_extra_body(extra_body)
50
+ return nil if extra_body.nil?
51
+ raise ArgumentError, "extra_body must be a Hash, got #{extra_body.class}" unless extra_body.is_a?(Hash)
52
+
53
+ Util.deep_stringify(extra_body)
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypeSafe
4
+ module Resources
5
+ # Access to the models available to the account, reached through Client#models.
6
+ class Models
7
+ def initialize(requestor)
8
+ @requestor = requestor
9
+ end
10
+
11
+ # List the models and aliases the account can send in the model field.
12
+ def list(request_options: {})
13
+ response = @requestor.get(Constants::MODELS_PATH, options: RequestOptions.from(request_options))
14
+ Responses::ListModelsResponse.from_http(response)
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypeSafe
4
+ module Responses
5
+ # Base class for answers. .from_hash dispatches on the "type" field.
6
+ class Answer
7
+ # "noul", "choice", "score", or an unrecognized type.
8
+ attr_reader :type
9
+ # The answer exactly as the API returned it, including unknown fields.
10
+ attr_reader :raw
11
+
12
+ def self.from_hash(hash, path)
13
+ data = Reader.hash!(hash, path)
14
+ type = Reader.string!(data["type"], "#{path}.type")
15
+ klass = TYPES.fetch(type, UnknownAnswer)
16
+ klass.parse(data, path)
17
+ end
18
+
19
+ def initialize(raw)
20
+ @raw = raw.freeze
21
+ @type = raw["type"]
22
+ end
23
+
24
+ # The raw answer.
25
+ def to_h
26
+ raw
27
+ end
28
+
29
+ def to_json(*args)
30
+ JSON.generate(raw, *args)
31
+ end
32
+
33
+ def ==(other)
34
+ other.class == self.class && raw == other.raw
35
+ end
36
+ alias eql? ==
37
+
38
+ def hash
39
+ raw.hash
40
+ end
41
+
42
+ def inspect
43
+ "#<#{self.class.name} #{raw.inspect}>"
44
+ end
45
+ end
46
+
47
+ # A yes/no answer.
48
+ class NoulAnswer < Answer
49
+ # Probability of yes, from 0 to 1.
50
+ attr_reader :noul
51
+
52
+ def self.parse(data, path)
53
+ new(data, noul: Reader.number!(data["noul"], "#{path}.noul"))
54
+ end
55
+
56
+ def initialize(raw, noul:)
57
+ @noul = noul
58
+ super(raw)
59
+ freeze
60
+ end
61
+ end
62
+
63
+ # A selected option with its probability distribution.
64
+ class ChoiceAnswer < Answer
65
+ # The highest-probability option.
66
+ attr_reader :choice
67
+ # Probability per option.
68
+ attr_reader :probabilities
69
+ # Certainty derived from the distribution, from 0 to 1.
70
+ attr_reader :confidence
71
+
72
+ def self.parse(data, path)
73
+ new(
74
+ data,
75
+ choice: Reader.string!(data["choice"], "#{path}.choice"),
76
+ probabilities: Reader.number_map!(data["probabilities"], "#{path}.probabilities"),
77
+ confidence: Reader.number!(data["confidence"], "#{path}.confidence")
78
+ )
79
+ end
80
+
81
+ def initialize(raw, choice:, probabilities:, confidence:)
82
+ @choice = choice
83
+ @probabilities = IndifferentHash.from(probabilities).freeze
84
+ @confidence = confidence
85
+ super(raw)
86
+ freeze
87
+ end
88
+ end
89
+
90
+ # An expected score with its rubric and probability per level.
91
+ class ScoreAnswer < Answer
92
+ # Probability-weighted score; may fall between integer levels.
93
+ attr_reader :score
94
+ # Level index to description.
95
+ attr_reader :legend
96
+ # Probability per level index.
97
+ attr_reader :probabilities
98
+ # Certainty derived from the distribution, from 0 to 1.
99
+ attr_reader :confidence
100
+
101
+ def self.parse(data, path)
102
+ new(
103
+ data,
104
+ score: Reader.number!(data["score"], "#{path}.score"),
105
+ legend: Reader.integer_keyed(Reader.hash!(data["legend"], "#{path}.legend"), "#{path}.legend"),
106
+ probabilities: Reader.integer_keyed(Reader.number_map!(data["probabilities"], "#{path}.probabilities"),
107
+ "#{path}.probabilities"),
108
+ confidence: Reader.number!(data["confidence"], "#{path}.confidence")
109
+ )
110
+ end
111
+
112
+ def initialize(raw, score:, legend:, probabilities:, confidence:)
113
+ @score = score
114
+ @legend = legend.freeze
115
+ @probabilities = probabilities.freeze
116
+ @confidence = confidence
117
+ super(raw)
118
+ freeze
119
+ end
120
+
121
+ # Legend with the original string keys.
122
+ def raw_legend
123
+ raw["legend"]
124
+ end
125
+
126
+ # Probabilities with the original string keys.
127
+ def raw_probabilities
128
+ raw["probabilities"]
129
+ end
130
+ end
131
+
132
+ # An answer whose type this gem does not recognize. Only #raw is available.
133
+ class UnknownAnswer < Answer
134
+ def self.parse(data, _path)
135
+ new(data)
136
+ end
137
+ end
138
+
139
+ Answer::TYPES = {
140
+ "noul" => NoulAnswer, "choice" => ChoiceAnswer, "score" => ScoreAnswer
141
+ }.freeze
142
+ end
143
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypeSafe
4
+ module Responses
5
+ # One model or alias the account can send in the model field.
6
+ class ModelCard
7
+ # The model ID or alias, e.g. "jev-latest".
8
+ attr_reader :name
9
+ # What the model is for.
10
+ attr_reader :description
11
+ # When the model or alias was released.
12
+ attr_reader :release_date
13
+ # The entry as returned by the API.
14
+ attr_reader :raw
15
+
16
+ def self.from_hash(hash, path)
17
+ data = Reader.hash!(hash, path)
18
+ new(
19
+ data,
20
+ name: Reader.string!(data["name"], "#{path}.name"),
21
+ description: Reader.optional_string(data["description"], "#{path}.description"),
22
+ release_date: Reader.optional_string(data["release_date"], "#{path}.release_date")
23
+ )
24
+ end
25
+
26
+ def initialize(raw, name:, description: nil, release_date: nil)
27
+ @raw = raw.freeze
28
+ @name = name
29
+ @description = description
30
+ @release_date = release_date
31
+ freeze
32
+ end
33
+
34
+ def to_h
35
+ raw
36
+ end
37
+
38
+ def ==(other)
39
+ other.is_a?(ModelCard) && raw == other.raw
40
+ end
41
+ alias eql? ==
42
+
43
+ def hash
44
+ raw.hash
45
+ end
46
+ end
47
+
48
+ # The models available to the account.
49
+ class ListModelsResponse
50
+ include Enumerable
51
+
52
+ attr_reader :models, :http_response, :raw
53
+
54
+ def self.from_http(response)
55
+ json = response.json
56
+ data = Reader.hash!(json, "$")
57
+ new(models: parse_models(data["models"]), http_response: response, raw: data)
58
+ rescue InvalidField => e
59
+ raise Reader.validation_error(e, response, json)
60
+ end
61
+
62
+ def self.parse_models(list)
63
+ raise InvalidField.new("models", "expected an array, got #{list.class}") unless list.is_a?(Array)
64
+
65
+ list.each_with_index.map { |entry, index| ModelCard.from_hash(entry, "models[#{index}]") }
66
+ end
67
+
68
+ def initialize(models:, http_response: nil, raw: nil)
69
+ @models = models.freeze
70
+ @http_response = http_response
71
+ @raw = raw
72
+ freeze
73
+ end
74
+
75
+ def each(&)
76
+ models.each(&)
77
+ end
78
+
79
+ # Model names.
80
+ def names
81
+ models.map(&:name)
82
+ end
83
+
84
+ def request_id
85
+ http_response&.request_id
86
+ end
87
+
88
+ def to_h
89
+ raw || { "models" => models.map(&:to_h) }
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypeSafe
4
+ module Responses
5
+ # Raised internally while parsing a response body; converted to APIResponseValidationError.
6
+ class InvalidField < StandardError
7
+ attr_reader :field_path
8
+
9
+ def initialize(field_path, message)
10
+ @field_path = field_path
11
+ super("#{field_path}: #{message}")
12
+ end
13
+ end
14
+
15
+ # Typed accessors for JSON values that record the dotted path of any violation.
16
+ module Reader
17
+ module_function
18
+
19
+ # Build the public error for an InvalidField raised while parsing a response.
20
+ def validation_error(error, response, json)
21
+ APIResponseValidationError.new(
22
+ "Invalid response body: #{error.message}",
23
+ field_path: error.field_path,
24
+ status: response.status,
25
+ body: json || response.body,
26
+ headers: response.headers,
27
+ endpoint: response.request&.endpoint
28
+ )
29
+ end
30
+
31
+ def hash!(value, path)
32
+ raise InvalidField.new(path, "expected an object, got #{value.class}") unless value.is_a?(Hash)
33
+
34
+ value
35
+ end
36
+
37
+ def string!(value, path)
38
+ raise InvalidField.new(path, "expected a string, got #{value.class}") unless value.is_a?(String)
39
+
40
+ value
41
+ end
42
+
43
+ def number!(value, path)
44
+ raise InvalidField.new(path, "expected a number, got #{value.class}") unless value.is_a?(Numeric)
45
+ raise InvalidField.new(path, "expected a finite number") if value.is_a?(Float) && !value.finite?
46
+
47
+ value.to_f
48
+ end
49
+
50
+ def optional_integer(value, path)
51
+ return nil if value.nil?
52
+ raise InvalidField.new(path, "expected an integer, got #{value.class}") unless value.is_a?(Integer)
53
+
54
+ value
55
+ end
56
+
57
+ def optional_string(value, path)
58
+ value.nil? ? nil : string!(value, path)
59
+ end
60
+
61
+ def number_map!(value, path)
62
+ hash!(value, path).each_with_object({}) { |(key, v), result| result[key.to_s] = number!(v, "#{path}.#{key}") }
63
+ end
64
+
65
+ # Convert "0", "1", ... keys into Integers.
66
+ def integer_keyed(hash, path)
67
+ hash.each_with_object({}) do |(key, value), result|
68
+ raise InvalidField.new("#{path}.#{key}", "expected an integer level key") unless key.match?(/\A\d+\z/)
69
+
70
+ result[key.to_i] = value
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypeSafe
4
+ module Responses
5
+ # Answers keyed by question name, with the model that answered and token usage.
6
+ class SystemOneResponse
7
+ # The versioned model that answered, e.g. "jev-1.13.0".
8
+ attr_reader :model
9
+ attr_reader :usage
10
+ # Every answer keyed by question name.
11
+ attr_reader :answers
12
+ # The underlying HTTP response.
13
+ attr_reader :http_response
14
+ # The parsed JSON body.
15
+ attr_reader :raw
16
+
17
+ def self.from_http(response)
18
+ json = response.json
19
+ parse(json, response)
20
+ rescue InvalidField => e
21
+ raise Reader.validation_error(e, response, json)
22
+ end
23
+
24
+ def self.parse(json, response)
25
+ data = Reader.hash!(json, "$")
26
+ new(
27
+ model: Reader.string!(data["model"], "model"),
28
+ usage: Usage.from_hash(data["usage"], "usage"),
29
+ answers: parse_answers(data["answers"]),
30
+ http_response: response,
31
+ raw: data
32
+ )
33
+ end
34
+
35
+ def self.parse_answers(answers)
36
+ return {} if answers.nil?
37
+
38
+ Reader.hash!(answers, "answers").each_with_object({}) do |(name, answer), result|
39
+ result[name] = Answer.from_hash(answer, "answers.#{name}")
40
+ end
41
+ end
42
+
43
+ def initialize(model:, usage:, answers:, http_response: nil, raw: nil)
44
+ @model = model
45
+ @usage = usage
46
+ @answers = IndifferentHash.from(answers).freeze
47
+ @http_response = http_response
48
+ @raw = raw
49
+ freeze
50
+ end
51
+
52
+ # The answer for a question name.
53
+ def [](name)
54
+ answers[name]
55
+ end
56
+
57
+ # The x-typesafe-request-id header.
58
+ def request_id
59
+ http_response&.request_id
60
+ end
61
+
62
+ def nouls
63
+ answers_of(NoulAnswer)
64
+ end
65
+
66
+ def choices
67
+ answers_of(ChoiceAnswer)
68
+ end
69
+
70
+ def scores
71
+ answers_of(ScoreAnswer)
72
+ end
73
+
74
+ def to_h
75
+ raw || { "model" => model, "usage" => usage.to_h, "answers" => answers.transform_values(&:to_h) }
76
+ end
77
+
78
+ def to_json(*args)
79
+ JSON.generate(to_h, *args)
80
+ end
81
+
82
+ private
83
+
84
+ def answers_of(klass)
85
+ IndifferentHash.from(answers.select { |_, answer| answer.is_a?(klass) }).freeze
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypeSafe
4
+ module Responses
5
+ # Token counts for a request, when reported by the API.
6
+ class Usage
7
+ attr_reader :input_tokens, :output_tokens
8
+
9
+ def self.from_hash(hash, path = "usage")
10
+ data = Reader.hash!(hash, path)
11
+ new(
12
+ input_tokens: Reader.optional_integer(data["input_tokens"], "#{path}.input_tokens"),
13
+ output_tokens: Reader.optional_integer(data["output_tokens"], "#{path}.output_tokens")
14
+ )
15
+ end
16
+
17
+ def initialize(input_tokens: nil, output_tokens: nil)
18
+ @input_tokens = input_tokens
19
+ @output_tokens = output_tokens
20
+ freeze
21
+ end
22
+
23
+ def to_h
24
+ { "input_tokens" => input_tokens, "output_tokens" => output_tokens }
25
+ end
26
+
27
+ def ==(other)
28
+ other.is_a?(Usage) && to_h == other.to_h
29
+ end
30
+ alias eql? ==
31
+
32
+ def hash
33
+ to_h.hash
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypeSafe
4
+ # Retry configuration. Defaults match the official TypeSafe SDKs: two retries with
5
+ # exponential backoff and jitter, honoring Retry-After, for 408, 429 and 5xx responses
6
+ # as well as connection failures and timeouts.
7
+ #
8
+ # Policies are frozen values. Build one with keyword overrides, or derive a new one
9
+ # from an existing policy with #with.
10
+ class RetryPolicy
11
+ DEFAULT_HTTP_STATUSES = ([408, 429] + (500..599).to_a).freeze
12
+
13
+ # Maximum retries after the initial attempt; 0 disables retries.
14
+ attr_reader :max_retries
15
+ # First backoff delay in seconds, doubled each attempt up to backoff_max.
16
+ attr_reader :backoff_initial
17
+ # Maximum backoff delay in seconds.
18
+ attr_reader :backoff_max
19
+ # Fraction of each backoff delay randomly subtracted, from 0 to 1.
20
+ attr_reader :backoff_jitter
21
+ # HTTP status codes that are retried.
22
+ attr_reader :http_statuses
23
+ # Whether to honor Retry-After and retry-after-ms response headers.
24
+ attr_reader :respect_retry_after
25
+ # Maximum server-requested delay in seconds; longer delays fall back to backoff.
26
+ attr_reader :max_retry_after
27
+ # Whether to retry APIConnectionError.
28
+ attr_reader :api_connection_error
29
+ # Whether to retry APITimeoutError.
30
+ attr_reader :api_timeout_error
31
+ # Total budget in seconds per call including delays; nil disables the limit.
32
+ attr_reader :timeout
33
+
34
+ # Coerce nil, a Hash of overrides, or a RetryPolicy into a RetryPolicy.
35
+ # Hash overrides are applied on top of +base+ (the SDK defaults unless given).
36
+ def self.from(value, base: nil)
37
+ base ||= DEFAULT
38
+ case value
39
+ when nil then base
40
+ when RetryPolicy then value
41
+ when Hash then base.with(**value.transform_keys(&:to_sym))
42
+ else raise ArgumentError, "retry_policy must be a RetryPolicy or a Hash, got #{value.class}"
43
+ end
44
+ end
45
+
46
+ def initialize(max_retries: 2, backoff_initial: 0.5, backoff_max: 5.0, backoff_jitter: 0.25,
47
+ http_statuses: DEFAULT_HTTP_STATUSES, respect_retry_after: true, max_retry_after: 60.0,
48
+ api_connection_error: true, api_timeout_error: true, timeout: 30.0)
49
+ @max_retries = non_negative_integer(:max_retries, max_retries)
50
+ @backoff_initial = non_negative_number(:backoff_initial, backoff_initial)
51
+ @backoff_max = non_negative_number(:backoff_max, backoff_max)
52
+ @backoff_jitter = fraction(:backoff_jitter, backoff_jitter)
53
+ @http_statuses = http_statuses.to_a.map(&:to_i).freeze
54
+ @respect_retry_after = respect_retry_after ? true : false
55
+ @max_retry_after = non_negative_number(:max_retry_after, max_retry_after)
56
+ @api_connection_error = api_connection_error ? true : false
57
+ @api_timeout_error = api_timeout_error ? true : false
58
+ @timeout = timeout.nil? ? nil : non_negative_number(:timeout, timeout)
59
+ freeze
60
+ end
61
+
62
+ def to_h
63
+ {
64
+ max_retries: max_retries, backoff_initial: backoff_initial, backoff_max: backoff_max,
65
+ backoff_jitter: backoff_jitter, http_statuses: http_statuses, respect_retry_after: respect_retry_after,
66
+ max_retry_after: max_retry_after, api_connection_error: api_connection_error,
67
+ api_timeout_error: api_timeout_error, timeout: timeout
68
+ }
69
+ end
70
+
71
+ # A copy of this policy with the given fields replaced.
72
+ def with(**overrides)
73
+ self.class.new(**to_h, **overrides)
74
+ end
75
+
76
+ def ==(other)
77
+ other.is_a?(RetryPolicy) && to_h == other.to_h
78
+ end
79
+ alias eql? ==
80
+
81
+ def hash
82
+ to_h.hash
83
+ end
84
+
85
+ def retry_status?(status)
86
+ http_statuses.include?(status)
87
+ end
88
+
89
+ # Whether a connection-level error should be retried.
90
+ def retry_error?(error)
91
+ case error
92
+ when APITimeoutError then api_timeout_error
93
+ when APIConnectionError then api_connection_error
94
+ else false
95
+ end
96
+ end
97
+
98
+ # Seconds to wait before the retry following +attempt+ (0 for the first retry).
99
+ # Uses the server's Retry-After when present and within max_retry_after, otherwise
100
+ # capped exponential backoff with jitter.
101
+ def delay_for(attempt, headers: nil, random: Kernel)
102
+ if respect_retry_after && headers
103
+ server_delay = Util.parse_retry_after(headers)
104
+ return server_delay if server_delay && server_delay <= max_retry_after
105
+ end
106
+
107
+ exponential = [backoff_initial * (2**attempt), backoff_max].min
108
+ exponential * (1 - (random.rand * backoff_jitter))
109
+ end
110
+
111
+ private
112
+
113
+ def non_negative_integer(name, value)
114
+ return value if value.is_a?(Integer) && value >= 0
115
+
116
+ raise ArgumentError, "#{name} must be a non-negative Integer, got #{value.inspect}"
117
+ end
118
+
119
+ def non_negative_number(name, value)
120
+ return value if value.is_a?(Numeric) && value.finite? && value >= 0
121
+
122
+ raise ArgumentError, "#{name} must be a non-negative number, got #{value.inspect}"
123
+ end
124
+
125
+ def fraction(name, value)
126
+ return value if value.is_a?(Numeric) && value >= 0 && value <= 1
127
+
128
+ raise ArgumentError, "#{name} must be between 0 and 1, got #{value.inspect}"
129
+ end
130
+ end
131
+
132
+ RetryPolicy::DEFAULT = RetryPolicy.new
133
+ end