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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 331918548db2a5fdbd2f3dc3c2ac3bf650ff80d530e1bf5df205ddabede2299b
4
- data.tar.gz: ce1c7bef8487e643d53fcfa8cb338fdb4abd26a085c19478e50cb53eac01d336
3
+ metadata.gz: 0627352ab282ec9a72607ebbc8d39bc73e2577231c567b4c66a7fc74e6140a0e
4
+ data.tar.gz: 218abce1d559c90b75e1b6bcf5872ff3f661d453dc6dd5c9bd15613141a89f1a
5
5
  SHA512:
6
- metadata.gz: 18dee0e01b1df600771dcc3eff223841f7074df76f309494dbdfc3b200ff6db4cc6627c6ce8cd0a3934665302385b07986e6a66f3bd96b51af3126f61d95e823
7
- data.tar.gz: cb4261224cfec035eed1b1bac36a02f84cc388a9ff05997aff7d9bc59a35a31108465eb6bd277cd94c0296408321ae17c8c8883b41718b1d95a9b363236ec120
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.
@@ -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 [Net::HTTPClientException, Net::HTTPFatalError] on any non-2xx
59
- # HTTP response.
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.value
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 [Net::HTTPClientException, Net::HTTPFatalError] on any non-2xx
39
- # HTTP response.
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,
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Typesafe
4
- VERSION = "0.6.0"
4
+ VERSION = "0.7.0"
5
5
  end
data/lib/typesafe.rb CHANGED
@@ -16,6 +16,7 @@ require_relative "typesafe/score_answer"
16
16
 
17
17
  require_relative "typesafe/usage"
18
18
  require_relative "typesafe/response"
19
+ require_relative "typesafe/errors"
19
20
  require_relative "typesafe/client"
20
21
  require_relative "typesafe/jev"
21
22
 
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.6.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