typesafe-sdk 0.2.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +11 -0
- data/LICENSE.txt +21 -0
- data/README.md +359 -0
- data/lib/typesafe/sdk/answer_parser.rb +36 -0
- data/lib/typesafe/sdk/api_error.rb +43 -0
- data/lib/typesafe/sdk/api_error_factory.rb +36 -0
- data/lib/typesafe/sdk/api_response_validation_error.rb +20 -0
- data/lib/typesafe/sdk/api_timeout_error.rb +14 -0
- data/lib/typesafe/sdk/body_decoder.rb +18 -0
- data/lib/typesafe/sdk/choice.rb +39 -0
- data/lib/typesafe/sdk/choice_answer.rb +45 -0
- data/lib/typesafe/sdk/client.rb +108 -0
- data/lib/typesafe/sdk/configuration.rb +66 -0
- data/lib/typesafe/sdk/connection_pool.rb +82 -0
- data/lib/typesafe/sdk/error_message.rb +51 -0
- data/lib/typesafe/sdk/field_reader.rb +76 -0
- data/lib/typesafe/sdk/http_request.rb +30 -0
- data/lib/typesafe/sdk/http_response.rb +24 -0
- data/lib/typesafe/sdk/invalid_response_field.rb +14 -0
- data/lib/typesafe/sdk/json_value.rb +51 -0
- data/lib/typesafe/sdk/list_models_response.rb +41 -0
- data/lib/typesafe/sdk/model_metadata.rb +39 -0
- data/lib/typesafe/sdk/models.rb +37 -0
- data/lib/typesafe/sdk/net_http_transport.rb +53 -0
- data/lib/typesafe/sdk/noul.rb +49 -0
- data/lib/typesafe/sdk/noul_answer.rb +39 -0
- data/lib/typesafe/sdk/question_set.rb +55 -0
- data/lib/typesafe/sdk/rate_limit_error.rb +14 -0
- data/lib/typesafe/sdk/request_builder.rb +58 -0
- data/lib/typesafe/sdk/request_logger.rb +68 -0
- data/lib/typesafe/sdk/requester.rb +92 -0
- data/lib/typesafe/sdk/retry_after.rb +46 -0
- data/lib/typesafe/sdk/retry_policy.rb +96 -0
- data/lib/typesafe/sdk/score.rb +40 -0
- data/lib/typesafe/sdk/score_answer.rb +53 -0
- data/lib/typesafe/sdk/stderr_logger.rb +42 -0
- data/lib/typesafe/sdk/system_one_response.rb +48 -0
- data/lib/typesafe/sdk/usage.rb +37 -0
- data/lib/typesafe/sdk/version.rb +7 -0
- data/lib/typesafe/sdk.rb +47 -0
- metadata +108 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Typesafe
|
|
4
|
+
module SDK
|
|
5
|
+
class Noul
|
|
6
|
+
TYPE = "noul"
|
|
7
|
+
|
|
8
|
+
attr_reader :instructions, :criteria
|
|
9
|
+
|
|
10
|
+
def initialize(instructions: nil, criteria: nil)
|
|
11
|
+
unless criteria.nil? || criteria.is_a?(Hash)
|
|
12
|
+
raise(Error, "noul criteria must be a hash with true and false descriptions")
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
@instructions = instructions
|
|
16
|
+
@criteria = criteria
|
|
17
|
+
freeze
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def type
|
|
21
|
+
TYPE
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def to_h
|
|
25
|
+
hash = { "type" => TYPE }
|
|
26
|
+
hash["instructions"] = JsonValue.normalize(instructions, path: "instructions") unless instructions.nil?
|
|
27
|
+
hash["criteria"] = normalized_criteria unless criteria.nil?
|
|
28
|
+
hash
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def ==(other)
|
|
32
|
+
other.is_a?(self.class) && other.to_h == to_h
|
|
33
|
+
end
|
|
34
|
+
alias eql? ==
|
|
35
|
+
|
|
36
|
+
def hash
|
|
37
|
+
[self.class, to_h].hash
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def normalized_criteria
|
|
43
|
+
criteria.each_with_object({}) do |(key, value), result|
|
|
44
|
+
result[key.to_s] = JsonValue.normalize(value, path: "criteria.#{key}")
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Typesafe
|
|
4
|
+
module SDK
|
|
5
|
+
class NoulAnswer
|
|
6
|
+
TYPE = "noul"
|
|
7
|
+
|
|
8
|
+
attr_reader :noul
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
def from_hash(hash, path:)
|
|
12
|
+
new(noul: FieldReader.number(hash: hash, key: "noul", path: path))
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(noul:)
|
|
17
|
+
@noul = noul
|
|
18
|
+
freeze
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def type
|
|
22
|
+
TYPE
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def to_h
|
|
26
|
+
{ type: TYPE, noul: noul }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def ==(other)
|
|
30
|
+
other.is_a?(self.class) && other.to_h == to_h
|
|
31
|
+
end
|
|
32
|
+
alias eql? ==
|
|
33
|
+
|
|
34
|
+
def hash
|
|
35
|
+
[self.class, to_h].hash
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Typesafe
|
|
4
|
+
module SDK
|
|
5
|
+
class QuestionSet
|
|
6
|
+
QUESTION_CLASSES = [Noul, Choice, Score].freeze
|
|
7
|
+
TYPES_REQUIRING_CRITERIA = %w[choice score].freeze
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
def normalize(questions)
|
|
11
|
+
raise(Error, "questions must be a hash of names to questions") unless questions.is_a?(Hash)
|
|
12
|
+
raise(Error, "at least one question is required") if questions.empty?
|
|
13
|
+
|
|
14
|
+
questions.each_with_object({}) do |(name, question), result|
|
|
15
|
+
unless name.is_a?(String) || name.is_a?(Symbol)
|
|
16
|
+
raise(Error, "question names must be strings or symbols, got #{name.class}")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
result[name.to_s] = normalize_question(name: name.to_s, question: question)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def normalize_question(name:, question:)
|
|
26
|
+
hash = question_hash(name: name, question: question)
|
|
27
|
+
type = hash["type"]
|
|
28
|
+
if TYPES_REQUIRING_CRITERIA.include?(type) && !hash.key?("criteria")
|
|
29
|
+
raise(Error, "question #{name.inspect} requires \"criteria\"")
|
|
30
|
+
end
|
|
31
|
+
validate_score_criteria(name: name, criteria: hash["criteria"]) if type == Score::TYPE
|
|
32
|
+
hash
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def question_hash(name:, question:)
|
|
36
|
+
return question.to_h if QUESTION_CLASSES.any? { |klass| question.is_a?(klass) }
|
|
37
|
+
|
|
38
|
+
hash = question.is_a?(Hash) ? JsonValue.normalize(question, path: "questions.#{name}") : nil
|
|
39
|
+
return hash if hash && hash["type"].is_a?(String) && !hash["type"].empty?
|
|
40
|
+
|
|
41
|
+
raise(
|
|
42
|
+
Error,
|
|
43
|
+
"question #{name.inspect} must be a Noul, Choice, or Score, or a hash with a nonempty \"type\""
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def validate_score_criteria(name:, criteria:)
|
|
48
|
+
return unless criteria.nil? || (criteria.respond_to?(:empty?) && criteria.empty?)
|
|
49
|
+
|
|
50
|
+
raise(Error, "score question #{name.inspect} has no criteria, at least one level is required")
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Typesafe
|
|
4
|
+
module SDK
|
|
5
|
+
class RateLimitError < APIError
|
|
6
|
+
attr_reader :retry_after_ms
|
|
7
|
+
|
|
8
|
+
def initialize(status:, body:, headers:, message: nil, endpoint: nil)
|
|
9
|
+
@retry_after_ms = RetryAfter.parse(headers)
|
|
10
|
+
super
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Typesafe
|
|
4
|
+
module SDK
|
|
5
|
+
class RequestBuilder
|
|
6
|
+
JSON_CONTENT_TYPE = "application/json"
|
|
7
|
+
RUNTIME = "ruby/#{RUBY_VERSION} (#{RUBY_PLATFORM})".freeze
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
def build(configuration:, http_method:, path:, body:, timeout:, headers:)
|
|
11
|
+
merged = configuration.headers.merge("User-Agent" => configuration.user_agent)
|
|
12
|
+
merge_headers(into: merged, headers: headers || {})
|
|
13
|
+
delete_header(headers: merged, name: RETRY_COUNT_HEADER)
|
|
14
|
+
protected_headers(configuration).each { |name, value| assign_header(headers: merged, name: name, value: value) }
|
|
15
|
+
assign_header(headers: merged, name: "Content-Type", value: JSON_CONTENT_TYPE) unless body.nil?
|
|
16
|
+
|
|
17
|
+
HTTPRequest.new(
|
|
18
|
+
http_method: http_method,
|
|
19
|
+
url: "#{configuration.base_url}#{path}",
|
|
20
|
+
headers: merged,
|
|
21
|
+
body: body.nil? ? nil : encode(body),
|
|
22
|
+
timeout: Configuration.validate_timeout(timeout.nil? ? configuration.timeout : timeout)
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def assign_header(headers:, name:, value:)
|
|
27
|
+
delete_header(headers: headers, name: name)
|
|
28
|
+
headers[name] = value
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def protected_headers(configuration)
|
|
34
|
+
{
|
|
35
|
+
"Authorization" => configuration.authorization,
|
|
36
|
+
"Accept" => JSON_CONTENT_TYPE,
|
|
37
|
+
"X-TypeSafe-SDK" => "#{SDK_NAME}/#{VERSION}",
|
|
38
|
+
"X-TypeSafe-Runtime" => RUNTIME
|
|
39
|
+
}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def merge_headers(into:, headers:)
|
|
43
|
+
headers.each { |name, value| assign_header(headers: into, name: name.to_s, value: value.to_s) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def delete_header(headers:, name:)
|
|
47
|
+
headers.delete_if { |existing, _value| existing.casecmp?(name) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def encode(body)
|
|
51
|
+
JSON.generate(body)
|
|
52
|
+
rescue JSON::GeneratorError, EncodingError => e
|
|
53
|
+
raise(Error, "the request body could not be encoded as json: #{e.message}")
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Typesafe
|
|
4
|
+
module SDK
|
|
5
|
+
class RequestLogger
|
|
6
|
+
SECRET_HEADERS = %w[authorization proxy-authorization x-api-key api-key cookie set-cookie].freeze
|
|
7
|
+
|
|
8
|
+
class << self
|
|
9
|
+
def redact(headers)
|
|
10
|
+
headers.to_h do |name, value|
|
|
11
|
+
[name, secret?(name) ? "***" : value]
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def secret?(name)
|
|
18
|
+
lowered = name.to_s.downcase
|
|
19
|
+
SECRET_HEADERS.include?(lowered) || lowered.include?("token") || lowered.include?("secret")
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def initialize(logger)
|
|
24
|
+
@logger = logger
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def sending(request)
|
|
28
|
+
return unless logger
|
|
29
|
+
|
|
30
|
+
logger.debug("sending #{describe(request)} headers=#{self.class.redact(request.headers)} body=#{request.body.inspect}")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def retrying(request:, retry_count:)
|
|
34
|
+
return unless logger
|
|
35
|
+
|
|
36
|
+
logger.info("retrying #{describe(request)} (retry #{retry_count})")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def failed(request:, error:)
|
|
40
|
+
return unless logger
|
|
41
|
+
|
|
42
|
+
logger.info("#{describe(request)} failed: #{error.message}")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def received(request:, response:, elapsed_ms:)
|
|
46
|
+
return unless logger
|
|
47
|
+
|
|
48
|
+
request_id = response.headers[REQUEST_ID_HEADER] || "-"
|
|
49
|
+
logger.info("#{describe(request)} returned #{response.status} in #{elapsed_ms.round}ms (request #{request_id})")
|
|
50
|
+
logger.debug("received #{describe(request)} headers=#{self.class.redact(response.headers)} body=#{response.body.inspect}")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def warn(message)
|
|
54
|
+
return unless logger
|
|
55
|
+
|
|
56
|
+
logger.warn(message)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
attr_reader :logger
|
|
62
|
+
|
|
63
|
+
def describe(request)
|
|
64
|
+
"#{request.http_method} #{request.url}"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Typesafe
|
|
4
|
+
module SDK
|
|
5
|
+
class Requester
|
|
6
|
+
def initialize(transport:, logger:)
|
|
7
|
+
@transport = transport
|
|
8
|
+
@request_logger = RequestLogger.new(logger)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def perform(request:, response_class:, retry_policy:)
|
|
12
|
+
started_at = monotonic_now
|
|
13
|
+
attempt = 1
|
|
14
|
+
begin
|
|
15
|
+
attempt_once(request: request, response_class: response_class, retry_count: attempt - 1)
|
|
16
|
+
rescue StandardError => e
|
|
17
|
+
delay = retry_delay(policy: retry_policy, error: e, attempt: attempt, started_at: started_at)
|
|
18
|
+
raise if delay.nil?
|
|
19
|
+
|
|
20
|
+
Kernel.sleep(delay) if delay.positive?
|
|
21
|
+
attempt += 1
|
|
22
|
+
retry
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
attr_reader :transport, :request_logger
|
|
29
|
+
|
|
30
|
+
def retry_delay(policy:, error:, attempt:, started_at:)
|
|
31
|
+
return unless policy.retryable?(error)
|
|
32
|
+
return if attempt >= policy.max_attempts
|
|
33
|
+
|
|
34
|
+
delay = policy.delay_for(attempt: attempt, error: error)
|
|
35
|
+
return delay if policy.timeout.nil?
|
|
36
|
+
return delay if monotonic_now - started_at + delay < policy.timeout
|
|
37
|
+
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def attempt_once(request:, response_class:, retry_count:)
|
|
42
|
+
attempt_request = request
|
|
43
|
+
if retry_count.positive?
|
|
44
|
+
headers = request.headers.dup
|
|
45
|
+
RequestBuilder.assign_header(headers: headers, name: RETRY_COUNT_HEADER, value: retry_count.to_s)
|
|
46
|
+
attempt_request = request.with_headers(headers)
|
|
47
|
+
request_logger.retrying(request: request, retry_count: retry_count)
|
|
48
|
+
end
|
|
49
|
+
response = send_request(attempt_request)
|
|
50
|
+
parse(request: request, response: response, response_class: response_class)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def send_request(request)
|
|
54
|
+
request_logger.sending(request)
|
|
55
|
+
started_at = monotonic_now
|
|
56
|
+
response = transport.call(request)
|
|
57
|
+
request_logger.received(request: request, response: response, elapsed_ms: (monotonic_now - started_at) * 1000)
|
|
58
|
+
response
|
|
59
|
+
rescue APIConnectionError => e
|
|
60
|
+
request_logger.failed(request: request, error: e)
|
|
61
|
+
raise
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def parse(request:, response:, response_class:)
|
|
65
|
+
raise(APIErrorFactory.build(response: response, endpoint: request.endpoint)) unless response.success?
|
|
66
|
+
|
|
67
|
+
body = parse_body(response)
|
|
68
|
+
response_class.from_body(body, request_logger: request_logger, http_response: response)
|
|
69
|
+
rescue InvalidResponseField => e
|
|
70
|
+
raise(
|
|
71
|
+
APIResponseValidationError.new(
|
|
72
|
+
status: response.status,
|
|
73
|
+
body: BodyDecoder.decode(response.body),
|
|
74
|
+
headers: response.headers,
|
|
75
|
+
field_path: e.field_path,
|
|
76
|
+
endpoint: request.endpoint
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def parse_body(response)
|
|
82
|
+
JSON.parse(response.body.dup.force_encoding(Encoding::UTF_8))
|
|
83
|
+
rescue JSON::ParserError, EncodingError
|
|
84
|
+
raise(InvalidResponseField, "")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def monotonic_now
|
|
88
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Typesafe
|
|
4
|
+
module SDK
|
|
5
|
+
class RetryAfter
|
|
6
|
+
HEADERS = [["retry-after-ms", 1], ["retry-after", 1000]].freeze
|
|
7
|
+
|
|
8
|
+
class << self
|
|
9
|
+
def parse(headers)
|
|
10
|
+
HEADERS.each do |name, multiplier|
|
|
11
|
+
raw = headers[name]
|
|
12
|
+
next if raw.nil?
|
|
13
|
+
|
|
14
|
+
stripped = raw.to_s.strip
|
|
15
|
+
value = Float(stripped.empty? ? "0" : stripped, exception: false)
|
|
16
|
+
if value.nil?
|
|
17
|
+
return from_http_date(stripped) if name == "retry-after" && http_date?(stripped)
|
|
18
|
+
|
|
19
|
+
next
|
|
20
|
+
end
|
|
21
|
+
next unless value.finite?
|
|
22
|
+
return if value.negative? && name == "retry-after"
|
|
23
|
+
next if value.negative?
|
|
24
|
+
|
|
25
|
+
delay = value * multiplier
|
|
26
|
+
return delay if delay.finite?
|
|
27
|
+
end
|
|
28
|
+
nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def http_date?(value)
|
|
34
|
+
Time.httpdate(value)
|
|
35
|
+
true
|
|
36
|
+
rescue ArgumentError
|
|
37
|
+
false
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def from_http_date(value)
|
|
41
|
+
[0.0, (Time.httpdate(value) - Time.now) * 1000].max
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Typesafe
|
|
4
|
+
module SDK
|
|
5
|
+
class RetryPolicy
|
|
6
|
+
DEFAULT_HTTP_STATUSES = [408, 429, *500..599].freeze
|
|
7
|
+
|
|
8
|
+
attr_reader :max_retries, :backoff_initial, :backoff_max, :backoff_jitter, :http_statuses,
|
|
9
|
+
:respect_retry_after, :api_connection_error, :api_timeout_error, :exceptions, :predicate, :timeout
|
|
10
|
+
|
|
11
|
+
def initialize(
|
|
12
|
+
max_retries: 2,
|
|
13
|
+
backoff_initial: 0.5,
|
|
14
|
+
backoff_max: 5.0,
|
|
15
|
+
backoff_jitter: 0.25,
|
|
16
|
+
http_statuses: DEFAULT_HTTP_STATUSES,
|
|
17
|
+
respect_retry_after: true,
|
|
18
|
+
api_connection_error: true,
|
|
19
|
+
api_timeout_error: true,
|
|
20
|
+
exceptions: [],
|
|
21
|
+
predicate: nil,
|
|
22
|
+
timeout: 30.0
|
|
23
|
+
)
|
|
24
|
+
@max_retries = max_retries
|
|
25
|
+
@backoff_initial = backoff_initial
|
|
26
|
+
@backoff_max = backoff_max
|
|
27
|
+
@backoff_jitter = backoff_jitter
|
|
28
|
+
@http_statuses = http_statuses.to_a.freeze
|
|
29
|
+
@respect_retry_after = respect_retry_after
|
|
30
|
+
@api_connection_error = api_connection_error
|
|
31
|
+
@api_timeout_error = api_timeout_error
|
|
32
|
+
@exceptions = exceptions.to_a.freeze
|
|
33
|
+
@predicate = predicate
|
|
34
|
+
@timeout = timeout
|
|
35
|
+
validate
|
|
36
|
+
freeze
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def max_attempts
|
|
40
|
+
max_retries + 1
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def retryable?(error)
|
|
44
|
+
builtin_retryable?(error) ||
|
|
45
|
+
exceptions.any? { |klass| error.is_a?(klass) } ||
|
|
46
|
+
(!predicate.nil? && predicate.call(error) ? true : false)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def delay_for(attempt:, error:)
|
|
50
|
+
if respect_retry_after && error.is_a?(APIError)
|
|
51
|
+
retry_after_ms = RetryAfter.parse(error.headers)
|
|
52
|
+
return retry_after_ms / 1000.0 if retry_after_ms
|
|
53
|
+
end
|
|
54
|
+
backoff(attempt)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def backoff(attempt)
|
|
58
|
+
return 0.0 if backoff_initial.zero? || backoff_max.zero?
|
|
59
|
+
|
|
60
|
+
exponent = attempt - 1
|
|
61
|
+
capped = exponent >= Math.log2(backoff_max) - Math.log2(backoff_initial)
|
|
62
|
+
exponential = capped ? backoff_max.to_f : backoff_initial * (2**exponent)
|
|
63
|
+
jittered = exponential * (1 - (Random.rand * backoff_jitter))
|
|
64
|
+
[exponential, jittered.round(3)].min
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def builtin_retryable?(error)
|
|
70
|
+
case error
|
|
71
|
+
when APITimeoutError then api_timeout_error
|
|
72
|
+
when APIConnectionError then api_connection_error
|
|
73
|
+
when APIError then http_statuses.include?(error.status)
|
|
74
|
+
else false
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def validate
|
|
79
|
+
raise(Error, "max_retries must be a non-negative integer") unless max_retries.is_a?(Integer) && max_retries >= 0
|
|
80
|
+
|
|
81
|
+
validate_seconds(name: "backoff_initial", value: backoff_initial)
|
|
82
|
+
validate_seconds(name: "backoff_max", value: backoff_max)
|
|
83
|
+
unless backoff_jitter.is_a?(Numeric) && backoff_jitter.between?(0, 1)
|
|
84
|
+
raise(Error, "backoff_jitter must be between zero and one")
|
|
85
|
+
end
|
|
86
|
+
Configuration.validate_timeout(timeout) unless timeout.nil?
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def validate_seconds(name:, value:)
|
|
90
|
+
return if value.is_a?(Numeric) && value.finite? && value >= 0
|
|
91
|
+
|
|
92
|
+
raise(Error, "#{name} must be a non-negative, finite number of seconds")
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Typesafe
|
|
4
|
+
module SDK
|
|
5
|
+
class Score
|
|
6
|
+
TYPE = "score"
|
|
7
|
+
|
|
8
|
+
attr_reader :criteria, :instructions
|
|
9
|
+
|
|
10
|
+
def initialize(criteria:, instructions: nil)
|
|
11
|
+
raise(Error, "score criteria must be an array of level descriptions") unless criteria.is_a?(Array)
|
|
12
|
+
raise(Error, "score criteria must include at least one level") if criteria.empty?
|
|
13
|
+
|
|
14
|
+
@criteria = criteria
|
|
15
|
+
@instructions = instructions
|
|
16
|
+
freeze
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def type
|
|
20
|
+
TYPE
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def to_h
|
|
24
|
+
hash = { "type" => TYPE }
|
|
25
|
+
hash["instructions"] = JsonValue.normalize(instructions, path: "instructions") unless instructions.nil?
|
|
26
|
+
hash["criteria"] = JsonValue.normalize(criteria, path: "criteria")
|
|
27
|
+
hash
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def ==(other)
|
|
31
|
+
other.is_a?(self.class) && other.to_h == to_h
|
|
32
|
+
end
|
|
33
|
+
alias eql? ==
|
|
34
|
+
|
|
35
|
+
def hash
|
|
36
|
+
[self.class, to_h].hash
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Typesafe
|
|
4
|
+
module SDK
|
|
5
|
+
class ScoreAnswer
|
|
6
|
+
TYPE = "score"
|
|
7
|
+
|
|
8
|
+
attr_reader :score, :confidence, :legend, :probabilities
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
def from_hash(hash, path:)
|
|
12
|
+
new(
|
|
13
|
+
score: FieldReader.number(hash: hash, key: "score", path: path),
|
|
14
|
+
confidence: FieldReader.number(hash: hash, key: "confidence", path: path),
|
|
15
|
+
legend: FieldReader.integer_keys(
|
|
16
|
+
hash: FieldReader.content_map(hash: hash, key: "legend", path: path),
|
|
17
|
+
path: "#{path}.legend"
|
|
18
|
+
),
|
|
19
|
+
probabilities: FieldReader.integer_keys(
|
|
20
|
+
hash: FieldReader.number_map(hash: hash, key: "probabilities", path: path),
|
|
21
|
+
path: "#{path}.probabilities"
|
|
22
|
+
)
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def initialize(score:, confidence:, legend:, probabilities:)
|
|
28
|
+
@score = score
|
|
29
|
+
@confidence = confidence
|
|
30
|
+
@legend = legend.freeze
|
|
31
|
+
@probabilities = probabilities.freeze
|
|
32
|
+
freeze
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def type
|
|
36
|
+
TYPE
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def to_h
|
|
40
|
+
{ type: TYPE, score: score, confidence: confidence, legend: legend, probabilities: probabilities }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def ==(other)
|
|
44
|
+
other.is_a?(self.class) && other.to_h == to_h
|
|
45
|
+
end
|
|
46
|
+
alias eql? ==
|
|
47
|
+
|
|
48
|
+
def hash
|
|
49
|
+
[self.class, to_h].hash
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Typesafe
|
|
4
|
+
module SDK
|
|
5
|
+
class StderrLogger
|
|
6
|
+
LEVELS = { "debug" => 0, "info" => 1, "warn" => 2, "warning" => 2, "error" => 3 }.freeze
|
|
7
|
+
|
|
8
|
+
attr_reader :level
|
|
9
|
+
|
|
10
|
+
def initialize(level: "warn", io: $stderr)
|
|
11
|
+
@level = LEVELS.fetch(level.to_s)
|
|
12
|
+
@io = io
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def debug(message = nil)
|
|
16
|
+
write(severity: "debug", message: message)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def info(message = nil)
|
|
20
|
+
write(severity: "info", message: message)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def warn(message = nil)
|
|
24
|
+
write(severity: "warn", message: message)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def error(message = nil)
|
|
28
|
+
write(severity: "error", message: message)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
attr_reader :io
|
|
34
|
+
|
|
35
|
+
def write(severity:, message:)
|
|
36
|
+
return if LEVELS.fetch(severity) < level
|
|
37
|
+
|
|
38
|
+
io.puts("typesafe-sdk #{severity}: #{message}")
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|