flightbyte 1.0.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.
@@ -0,0 +1,228 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module FlightByte
6
+ # Base class of every error this gem raises. Invalid arguments raise
7
+ # `ArgumentError` instead, before anything is sent.
8
+ class Error < StandardError; end
9
+
10
+ # A request got no HTTP response: a refused connection, a DNS failure, a TLS
11
+ # failure, a dropped socket, or the timeout ran out. The original exception
12
+ # is in `cause`.
13
+ class ConnectionError < Error
14
+ # @api private
15
+ def initialize(message, timed_out: false)
16
+ super(message)
17
+ @timed_out = timed_out
18
+ end
19
+
20
+ # Whether the client's own timeout ran out.
21
+ def timed_out?
22
+ @timed_out
23
+ end
24
+ end
25
+
26
+ # The API answered with a non-2xx status. The readers mirror the API's error
27
+ # body. Rescue the subclass you can handle:
28
+ #
29
+ # begin
30
+ # client.flights.get(carrier: "TK", flight_number: "1", date: "2026-09-08")
31
+ # rescue FlightByte::NotFoundError
32
+ # puts "No such flight that day"
33
+ # rescue FlightByte::RateLimitError => e
34
+ # sleep(e.retry_after || 5)
35
+ # retry
36
+ # end
37
+ class APIError < Error
38
+ # @return [Integer] The HTTP status.
39
+ attr_reader :status
40
+ # @return [Array<String>] Every message the API sent. Validation errors send one per field.
41
+ attr_reader :messages
42
+ # @return [Hash{String => Object}] The decoded error body; empty when it was not a JSON object.
43
+ attr_reader :body
44
+ # @return [String] The body exactly as received.
45
+ attr_reader :raw_body
46
+ # @return [Hash{String => String}] The response headers, with lower-case names.
47
+ attr_reader :headers
48
+
49
+ # @api private
50
+ # Builds the subclass for `status`.
51
+ def self.from(status, reason, raw_body, headers)
52
+ error_class_for(status).new(status, reason, raw_body, headers)
53
+ end
54
+
55
+ # @api private
56
+ def self.error_class_for(status)
57
+ case status
58
+ when 400 then BadRequestError
59
+ when 401 then AuthenticationError
60
+ when 402 then PaymentRequiredError
61
+ when 403 then PermissionDeniedError
62
+ when 404 then NotFoundError
63
+ when 429 then RateLimitError
64
+ when 502 then UpstreamError
65
+ when 500..599 then ServerError
66
+ else UnexpectedStatusError
67
+ end
68
+ end
69
+
70
+ # @api private
71
+ def initialize(status, reason, raw_body, headers)
72
+ @status = status
73
+ @raw_body = raw_body.to_s.dup.freeze
74
+ @headers = headers.dup.freeze
75
+ @body = decode(@raw_body).freeze
76
+ @messages = read_messages(@body).freeze
77
+ super(build_message(reason.to_s.strip))
78
+ end
79
+
80
+ # A machine-readable reason, when the API sends one, for example
81
+ # `watched_flight_limit` or `account_suspended`.
82
+ #
83
+ # @return [String, nil]
84
+ def code
85
+ string_field("code")
86
+ end
87
+
88
+ # Identifies the request; quote it when contacting support.
89
+ #
90
+ # @return [String, nil]
91
+ def request_id
92
+ string_field("requestId") || headers["x-request-id"]
93
+ end
94
+
95
+ private
96
+
97
+ def build_message(reason)
98
+ # HTTP/2 and some proxies send no reason phrase; the API's own fills in.
99
+ phrase = reason.empty? ? string_field("error").to_s : reason
100
+ label = [status, phrase].reject { |part| part.to_s.empty? }.join(" ")
101
+ message = messages.empty? ? label : "#{label}: #{messages.join("; ")}"
102
+ message += " (request #{request_id})" if request_id
103
+ message
104
+ end
105
+
106
+ def decode(raw)
107
+ parsed = JSON.parse(raw)
108
+ parsed.is_a?(Hash) ? parsed : {}
109
+ rescue JSON::ParserError, EncodingError
110
+ {}
111
+ end
112
+
113
+ def read_messages(body)
114
+ case (message = body["message"])
115
+ when String then [message]
116
+ when Array then message.map(&:to_s)
117
+ else []
118
+ end
119
+ end
120
+
121
+ def string_field(key)
122
+ value = body[key]
123
+ value.is_a?(String) ? value : nil
124
+ end
125
+
126
+ def number_field(key)
127
+ value = body[key]
128
+ value.is_a?(Numeric) && value.finite? ? value : nil
129
+ end
130
+ end
131
+
132
+ # 400: the request was invalid. When a flight number flies several legs that
133
+ # day and no `origin:` chose one, {#legs} lists them.
134
+ class BadRequestError < APIError
135
+ # The legs to choose from; pass one's `origin` or `flight_id` and try again.
136
+ #
137
+ # @return [Array<AmbiguousLeg>]
138
+ def legs
139
+ legs = body["legs"]
140
+ return [] unless legs.is_a?(Array)
141
+
142
+ legs.grep(Hash).map { |leg| AmbiguousLeg.new(leg) }
143
+ end
144
+ end
145
+
146
+ # 401: the API key is missing, invalid, revoked or expired.
147
+ class AuthenticationError < APIError; end
148
+
149
+ # 402: no watched flights are left. {#code} says why:
150
+ # `watched_flight_limit` (the plan's cap), `overage_cap_reached` (the
151
+ # account's overage cap) or `credits_exhausted` (prepaid credits used up).
152
+ class PaymentRequiredError < APIError
153
+ # Watched flights the usage period allows. Not with `credits_exhausted`.
154
+ # @return [Numeric, nil]
155
+ def limit = number_field("limit")
156
+
157
+ # Watched flights created this usage period. Not with `credits_exhausted`.
158
+ # @return [Numeric, nil]
159
+ def used = number_field("used")
160
+
161
+ # When the usage period, and so the count, resets, ISO 8601.
162
+ # @return [String, nil]
163
+ def resets_at = string_field("resetsAt")
164
+
165
+ # The overage cap in US cents; only with `overage_cap_reached`.
166
+ # @return [Numeric, nil]
167
+ def cap_cents = number_field("capCents")
168
+
169
+ # Prepaid credits left; only with `credits_exhausted`.
170
+ # @return [Numeric, nil]
171
+ def balance = number_field("balance")
172
+
173
+ # When the credits expire or expired, ISO 8601; only with `credits_exhausted`.
174
+ # @return [String, nil]
175
+ def expires_at = string_field("expiresAt")
176
+ end
177
+
178
+ # 403: the account is suspended (`code` `account_suspended`) or a token
179
+ # lacks the scope (`code` `insufficient_scope`).
180
+ class PermissionDeniedError < APIError; end
181
+
182
+ # 404: the flight did not operate that day, or no such subscription.
183
+ class NotFoundError < APIError; end
184
+
185
+ # 429: a minute, day or month quota is used up.
186
+ class RateLimitError < APIError
187
+ # The window that ran out: `minute`, `day` or `month`.
188
+ #
189
+ # @return [String, nil]
190
+ def window
191
+ string_field("window")
192
+ end
193
+
194
+ # How many seconds to wait before retrying, from the body's
195
+ # `retryAfterSeconds` or the `Retry-After` header.
196
+ #
197
+ # @return [Numeric, nil]
198
+ def retry_after
199
+ from_body = number_field("retryAfterSeconds")
200
+ return from_body if from_body
201
+
202
+ header = headers["retry-after"].to_s.strip
203
+ header.match?(/\A\d+\z/) ? Integer(header, 10) : nil
204
+ end
205
+ end
206
+
207
+ # 5xx: the API failed. {#request_id} is what support asks for.
208
+ class ServerError < APIError; end
209
+
210
+ # 502: the upstream flight data provider failed. Safe to retry after a short
211
+ # wait. It is also a {ServerError}.
212
+ class UpstreamError < ServerError; end
213
+
214
+ # Any other non-2xx status, including a redirect this client does not
215
+ # follow: one to another origin, or one for a request other than GET or
216
+ # HEAD. The target is in {#location}.
217
+ class UnexpectedStatusError < APIError
218
+ # Where a 3xx pointed, when it did.
219
+ #
220
+ # @return [String, nil]
221
+ def location
222
+ headers["location"]
223
+ end
224
+ end
225
+
226
+ # The webhook signature did not verify.
227
+ class WebhookSignatureError < Error; end
228
+ end