typesafe-jev 0.6.0 → 0.7.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 +4 -4
- data/CHANGELOG.md +4 -0
- data/lib/typesafe/client.rb +9 -3
- data/lib/typesafe/errors.rb +178 -0
- data/lib/typesafe/jev.rb +2 -2
- data/lib/typesafe/version.rb +1 -1
- data/lib/typesafe.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0627352ab282ec9a72607ebbc8d39bc73e2577231c567b4c66a7fc74e6140a0e
|
|
4
|
+
data.tar.gz: 218abce1d559c90b75e1b6bcf5872ff3f661d453dc6dd5c9bd15613141a89f1a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ff6c07bf97933605fe0aa29c6d63fc6ed2390fa9cde14e9fc0016d17c373b45a85482f58ccf98b4df5279bb1c43e9174db8521faa4599c0ab2656eb94bb04b48
|
|
7
|
+
data.tar.gz: d6cd7d83fcff85966c653434fa64a5fd87a0a5fa23a7970643c5669ac298b367a4ead66d7eb44ebefdb32ab4f2a54963af1bdf5a9eeb2254952ecca9b966addd
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.7.0
|
|
4
|
+
|
|
5
|
+
- **Breaking**: `Typesafe::Client#evaluate` (and `Typesafe::Jev.evaluate`) now raises typed `Typesafe::Error` subclasses on any non-2xx response instead of raw `Net::HTTPClientException`/`Net::HTTPFatalError`: `BadRequestError` (400), `AuthenticationError` (401), `PermissionDeniedError` (403), `NotFoundError` (404), `UnprocessableEntityError` (422, with `#errors`), `RateLimitError` (429, with `#retry_after`), `OverloadedError` (529) and `ServerError` (5xx), all under the `Typesafe::APIError` base with `status`, `body`, `headers`, `request_id` and `#retryable?`. `Typesafe::Error` is the root rescuable class.
|
|
6
|
+
|
|
3
7
|
## 0.6.0
|
|
4
8
|
|
|
5
9
|
- **Breaking**: `Typesafe::Client#evaluate` (and `Typesafe::Jev.evaluate`) now returns a typed `Typesafe::Response` instead of the raw parsed Hash; answers come back as `NoulAnswer`/`ChoiceAnswer`/`ScoreAnswer` objects accessible via `response[question_id]` (String or Symbol). Use `response.to_h` for the previous raw-Hash behavior.
|
data/lib/typesafe/client.rb
CHANGED
|
@@ -55,8 +55,11 @@ module Typesafe
|
|
|
55
55
|
# String/Symbol keys to Question values, +model+ is invalid, or the
|
|
56
56
|
# response body is not a valid response shape.
|
|
57
57
|
# @raise [JSON::ParserError] if the response body is not valid JSON.
|
|
58
|
-
# @raise [
|
|
59
|
-
#
|
|
58
|
+
# @raise [Typesafe::APIError] (or a subclass) on any non-2xx HTTP response:
|
|
59
|
+
# {Typesafe::BadRequestError}, {Typesafe::AuthenticationError},
|
|
60
|
+
# {Typesafe::PermissionDeniedError}, {Typesafe::NotFoundError},
|
|
61
|
+
# {Typesafe::UnprocessableEntityError}, {Typesafe::RateLimitError},
|
|
62
|
+
# {Typesafe::OverloadedError} and {Typesafe::ServerError}.
|
|
60
63
|
def evaluate(state:, questions:, model: nil)
|
|
61
64
|
model = model.nil? ? self.model : freeze_string(model, "model must be a non-empty String")
|
|
62
65
|
questions = validate_questions!(questions)
|
|
@@ -68,7 +71,10 @@ module Typesafe
|
|
|
68
71
|
)
|
|
69
72
|
|
|
70
73
|
response = post(body)
|
|
71
|
-
response.
|
|
74
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
75
|
+
raise Errors.from_response(status: response.code.to_i, headers: response, body: response.body)
|
|
76
|
+
end
|
|
77
|
+
|
|
72
78
|
Response.from_json(response.body)
|
|
73
79
|
end
|
|
74
80
|
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Typesafe
|
|
4
|
+
# Root of all errors raised by the gem, so users can `rescue Typesafe::Error`.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# A non-2xx HTTP response from the TypeSafe API.
|
|
8
|
+
#
|
|
9
|
+
# begin
|
|
10
|
+
# client.evaluate(state:, questions:)
|
|
11
|
+
# rescue Typesafe::RateLimitError => e
|
|
12
|
+
# sleep(e.retry_after || 1.0)
|
|
13
|
+
# retry
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# The `detail` field of an error body comes in three shapes, all of which
|
|
17
|
+
# are rendered into the exception message:
|
|
18
|
+
#
|
|
19
|
+
# * a plain String — `{"detail": "Unknown model: jev-99"}`
|
|
20
|
+
# * a Hash with error_type/message —
|
|
21
|
+
# `{"detail": {"error_type": "authentication_error", "message": "..."}}`
|
|
22
|
+
# * an Array of validation entries (FastAPI/pydantic style) —
|
|
23
|
+
# `{"detail": [{"type": "missing", "loc": ["body", "questions"], "msg": "Field required"}]}`
|
|
24
|
+
class APIError < Error
|
|
25
|
+
# @return [Integer] the HTTP status code.
|
|
26
|
+
attr_reader :status
|
|
27
|
+
# @return [Hash, Array, String, nil] the parsed JSON body (Hash or Array),
|
|
28
|
+
# the raw body String when it is not valid JSON, or nil for an empty body.
|
|
29
|
+
attr_reader :body
|
|
30
|
+
# @return [Hash{String => String}] the response headers, as received.
|
|
31
|
+
attr_reader :headers
|
|
32
|
+
# @return [String, nil] the +x-typesafe-request-id+ response header, when present.
|
|
33
|
+
attr_reader :request_id
|
|
34
|
+
|
|
35
|
+
# @param status [Integer] the HTTP status code.
|
|
36
|
+
# @param body [Hash, Array, String, nil] the parsed or raw response body.
|
|
37
|
+
# @param headers [Hash{String => String}] the response headers.
|
|
38
|
+
# @param message [String, nil] overrides the rendered +detail+-based message.
|
|
39
|
+
def initialize(status:, body: nil, headers: {}, message: nil)
|
|
40
|
+
@status = status
|
|
41
|
+
@body = body
|
|
42
|
+
@headers = headers
|
|
43
|
+
@request_id = header("x-typesafe-request-id")
|
|
44
|
+
super(message || "The TypeSafe API returned status #{status}.")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# True when the request may succeed if retried after a delay: rate limits
|
|
48
|
+
# (429), overload (529) and server errors (5xx).
|
|
49
|
+
# @return [Boolean]
|
|
50
|
+
def retryable?
|
|
51
|
+
status == 429 || status == 529 || (500..599).cover?(status)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
# Case-insensitive header lookup, since header casing depends on the server.
|
|
57
|
+
def header(name)
|
|
58
|
+
headers.each { |key, value| return value if key.to_s.downcase == name }
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The request was invalid (400).
|
|
64
|
+
class BadRequestError < APIError; end
|
|
65
|
+
|
|
66
|
+
# Missing or invalid API key (401).
|
|
67
|
+
class AuthenticationError < APIError; end
|
|
68
|
+
|
|
69
|
+
# Access denied (403). The API also returns this for a missing API key.
|
|
70
|
+
class PermissionDeniedError < APIError; end
|
|
71
|
+
|
|
72
|
+
# The endpoint or resource was not found (404).
|
|
73
|
+
class NotFoundError < APIError; end
|
|
74
|
+
|
|
75
|
+
# The request body failed server-side validation (422).
|
|
76
|
+
class UnprocessableEntityError < APIError
|
|
77
|
+
# The parsed validation entries, e.g.
|
|
78
|
+
# `{"type" => "missing", "loc" => ["body", "questions"], "msg" => "Field required"}`;
|
|
79
|
+
# empty when the body carries none.
|
|
80
|
+
# @return [Array<Hash>]
|
|
81
|
+
def errors
|
|
82
|
+
detail = body.is_a?(Hash) ? body["detail"] : nil
|
|
83
|
+
detail.is_a?(Array) ? detail : []
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The rate limit was exceeded (429).
|
|
88
|
+
class RateLimitError < APIError
|
|
89
|
+
# The server's requested wait before retrying, parsed from the
|
|
90
|
+
# +Retry-After+ (seconds) or +Retry-After-Ms+ (milliseconds) header.
|
|
91
|
+
# @return [Float, Integer, nil]
|
|
92
|
+
def retry_after
|
|
93
|
+
if (ms = header("retry-after-ms"))
|
|
94
|
+
Float(ms) / 1000
|
|
95
|
+
elsif (seconds = header("retry-after"))
|
|
96
|
+
Float(seconds)
|
|
97
|
+
end
|
|
98
|
+
rescue ArgumentError, TypeError
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# TypeSafe is temporarily overloaded (529).
|
|
104
|
+
class OverloadedError < APIError; end
|
|
105
|
+
|
|
106
|
+
# The server failed to process the request (5xx, excluding 529).
|
|
107
|
+
class ServerError < APIError; end
|
|
108
|
+
|
|
109
|
+
# Builds error instances from raw HTTP responses.
|
|
110
|
+
module Errors
|
|
111
|
+
module_function
|
|
112
|
+
|
|
113
|
+
# Maps a non-2xx response to the matching error class.
|
|
114
|
+
#
|
|
115
|
+
# @param status [Integer] the HTTP status code.
|
|
116
|
+
# @param headers [Hash, #each_header] the response headers.
|
|
117
|
+
# @param body [String, Hash, Array, nil] the raw or parsed response body.
|
|
118
|
+
# @return [APIError] an instance of the class matching +status+.
|
|
119
|
+
def from_response(status:, headers: {}, body: nil)
|
|
120
|
+
parsed_body = parse_body(body)
|
|
121
|
+
klass = STATUS_CLASSES.fetch(status) do
|
|
122
|
+
(500..599).cover?(status) ? ServerError : APIError
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
klass.new(
|
|
126
|
+
status: status,
|
|
127
|
+
body: parsed_body.is_a?(String) ? parsed_body.dup.freeze : parsed_body.freeze,
|
|
128
|
+
headers: normalize_headers(headers).freeze,
|
|
129
|
+
message: message_from_body(parsed_body, status)
|
|
130
|
+
)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def parse_body(body)
|
|
134
|
+
return body unless body.is_a?(String)
|
|
135
|
+
|
|
136
|
+
JSON.parse(body)
|
|
137
|
+
rescue JSON::ParserError, TypeError
|
|
138
|
+
body
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def message_from_body(parsed_body, status)
|
|
142
|
+
detail = parsed_body.is_a?(Hash) ? parsed_body["detail"] : nil
|
|
143
|
+
case detail
|
|
144
|
+
when String then detail
|
|
145
|
+
when Hash then detail["message"] || detail[:message] || "Invalid request."
|
|
146
|
+
when Array then detail.map { |entry| validation_message(entry) }.join("; ")
|
|
147
|
+
else "The TypeSafe API returned status #{status}."
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def validation_message(entry)
|
|
152
|
+
return entry.to_s unless entry.is_a?(Hash)
|
|
153
|
+
|
|
154
|
+
loc = Array(entry["loc"]).join(".")
|
|
155
|
+
msg = entry["msg"] || entry[:msg]
|
|
156
|
+
loc.empty? ? msg.to_s : "#{loc}: #{msg}"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def normalize_headers(headers)
|
|
160
|
+
return headers.each_header.to_h if headers.respond_to?(:each_header)
|
|
161
|
+
|
|
162
|
+
headers.dup
|
|
163
|
+
rescue TypeError
|
|
164
|
+
{}
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
STATUS_CLASSES = {
|
|
168
|
+
400 => BadRequestError,
|
|
169
|
+
401 => AuthenticationError,
|
|
170
|
+
403 => PermissionDeniedError,
|
|
171
|
+
404 => NotFoundError,
|
|
172
|
+
422 => UnprocessableEntityError,
|
|
173
|
+
429 => RateLimitError,
|
|
174
|
+
529 => OverloadedError
|
|
175
|
+
}.freeze
|
|
176
|
+
private_constant :STATUS_CLASSES
|
|
177
|
+
end
|
|
178
|
+
end
|
data/lib/typesafe/jev.rb
CHANGED
|
@@ -35,8 +35,8 @@ module Typesafe
|
|
|
35
35
|
# @raise [ArgumentError] if +questions+ is invalid, +model+ is not the
|
|
36
36
|
# pinned one, or the response body is not a valid response shape.
|
|
37
37
|
# @raise [JSON::ParserError] if the response body is not valid JSON.
|
|
38
|
-
# @raise [
|
|
39
|
-
#
|
|
38
|
+
# @raise [Typesafe::APIError] (or a subclass) on any non-2xx HTTP response;
|
|
39
|
+
# see {Typesafe::Client#evaluate}.
|
|
40
40
|
def evaluate(state:, questions:, model: nil)
|
|
41
41
|
if model && model != PINNED_MODEL
|
|
42
42
|
raise ArgumentError,
|
data/lib/typesafe/version.rb
CHANGED
data/lib/typesafe.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: typesafe-jev
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.7.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- dtheofr
|
|
@@ -66,6 +66,7 @@ files:
|
|
|
66
66
|
- lib/typesafe/choice.rb
|
|
67
67
|
- lib/typesafe/choice_answer.rb
|
|
68
68
|
- lib/typesafe/client.rb
|
|
69
|
+
- lib/typesafe/errors.rb
|
|
69
70
|
- lib/typesafe/jev.rb
|
|
70
71
|
- lib/typesafe/noul.rb
|
|
71
72
|
- lib/typesafe/noul_answer.rb
|