fizzy-sdk 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.
- checksums.yaml +7 -0
- data/.rubocop.yml +18 -0
- data/Rakefile +26 -0
- data/fizzy-sdk.gemspec +45 -0
- data/lib/fizzy/auth_strategy.rb +38 -0
- data/lib/fizzy/bulkhead.rb +68 -0
- data/lib/fizzy/cache.rb +101 -0
- data/lib/fizzy/chain_hooks.rb +45 -0
- data/lib/fizzy/circuit_breaker.rb +115 -0
- data/lib/fizzy/client.rb +212 -0
- data/lib/fizzy/config.rb +143 -0
- data/lib/fizzy/cookie_auth.rb +27 -0
- data/lib/fizzy/errors.rb +291 -0
- data/lib/fizzy/generated/metadata.json +1341 -0
- data/lib/fizzy/generated/services/boards_service.rb +91 -0
- data/lib/fizzy/generated/services/cards_service.rb +313 -0
- data/lib/fizzy/generated/services/columns_service.rb +69 -0
- data/lib/fizzy/generated/services/comments_service.rb +68 -0
- data/lib/fizzy/generated/services/devices_service.rb +35 -0
- data/lib/fizzy/generated/services/identity_service.rb +19 -0
- data/lib/fizzy/generated/services/miscellaneous_service.rb +256 -0
- data/lib/fizzy/generated/services/notifications_service.rb +65 -0
- data/lib/fizzy/generated/services/pins_service.rb +19 -0
- data/lib/fizzy/generated/services/reactions_service.rb +80 -0
- data/lib/fizzy/generated/services/sessions_service.rb +58 -0
- data/lib/fizzy/generated/services/steps_service.rb +69 -0
- data/lib/fizzy/generated/services/tags_service.rb +20 -0
- data/lib/fizzy/generated/services/uploads_service.rb +24 -0
- data/lib/fizzy/generated/services/users_service.rb +52 -0
- data/lib/fizzy/generated/services/webhooks_service.rb +83 -0
- data/lib/fizzy/generated/types.rb +988 -0
- data/lib/fizzy/hooks.rb +70 -0
- data/lib/fizzy/http.rb +411 -0
- data/lib/fizzy/logger_hooks.rb +46 -0
- data/lib/fizzy/magic_link_flow.rb +57 -0
- data/lib/fizzy/noop_hooks.rb +9 -0
- data/lib/fizzy/operation_info.rb +17 -0
- data/lib/fizzy/rate_limiter.rb +68 -0
- data/lib/fizzy/request_info.rb +10 -0
- data/lib/fizzy/request_result.rb +14 -0
- data/lib/fizzy/resilience.rb +59 -0
- data/lib/fizzy/security.rb +103 -0
- data/lib/fizzy/services/base_service.rb +116 -0
- data/lib/fizzy/static_token_provider.rb +24 -0
- data/lib/fizzy/token_provider.rb +42 -0
- data/lib/fizzy/version.rb +6 -0
- data/lib/fizzy/webhooks/verify.rb +36 -0
- data/lib/fizzy.rb +95 -0
- data/scripts/generate-metadata.rb +105 -0
- data/scripts/generate-services.rb +681 -0
- data/scripts/generate-types.rb +160 -0
- metadata +252 -0
data/lib/fizzy/config.rb
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Fizzy
|
|
6
|
+
# Configuration for the Fizzy API client.
|
|
7
|
+
#
|
|
8
|
+
# @example Creating config with defaults
|
|
9
|
+
# config = Fizzy::Config.new
|
|
10
|
+
#
|
|
11
|
+
# @example Creating config with custom values
|
|
12
|
+
# config = Fizzy::Config.new(
|
|
13
|
+
# base_url: "https://fizzy.do",
|
|
14
|
+
# timeout: 60,
|
|
15
|
+
# max_retries: 3
|
|
16
|
+
# )
|
|
17
|
+
#
|
|
18
|
+
# @example Loading config from environment
|
|
19
|
+
# config = Fizzy::Config.from_env
|
|
20
|
+
class Config
|
|
21
|
+
# @return [String] API base URL
|
|
22
|
+
attr_accessor :base_url
|
|
23
|
+
|
|
24
|
+
# @return [Integer] request timeout in seconds
|
|
25
|
+
attr_accessor :timeout
|
|
26
|
+
|
|
27
|
+
# @return [Integer] maximum retry attempts for GET requests
|
|
28
|
+
attr_accessor :max_retries
|
|
29
|
+
|
|
30
|
+
# @return [Float] initial backoff delay in seconds
|
|
31
|
+
attr_accessor :base_delay
|
|
32
|
+
|
|
33
|
+
# @return [Float] maximum jitter to add to delays in seconds
|
|
34
|
+
attr_accessor :max_jitter
|
|
35
|
+
|
|
36
|
+
# @return [Integer] maximum pages to fetch in paginated requests
|
|
37
|
+
attr_accessor :max_pages
|
|
38
|
+
|
|
39
|
+
# Default values
|
|
40
|
+
DEFAULT_BASE_URL = "https://fizzy.do"
|
|
41
|
+
DEFAULT_TIMEOUT = 30
|
|
42
|
+
DEFAULT_MAX_RETRIES = 3
|
|
43
|
+
DEFAULT_BASE_DELAY = 1.0
|
|
44
|
+
DEFAULT_MAX_JITTER = 0.1
|
|
45
|
+
DEFAULT_MAX_PAGES = 10_000
|
|
46
|
+
|
|
47
|
+
# Creates a new configuration with the given options.
|
|
48
|
+
#
|
|
49
|
+
# @param base_url [String] API base URL
|
|
50
|
+
# @param timeout [Integer] request timeout in seconds
|
|
51
|
+
# @param max_retries [Integer] maximum retry attempts
|
|
52
|
+
# @param base_delay [Float] initial backoff delay
|
|
53
|
+
# @param max_jitter [Float] maximum jitter
|
|
54
|
+
# @param max_pages [Integer] maximum pages to fetch
|
|
55
|
+
def initialize(
|
|
56
|
+
base_url: DEFAULT_BASE_URL,
|
|
57
|
+
timeout: DEFAULT_TIMEOUT,
|
|
58
|
+
max_retries: DEFAULT_MAX_RETRIES,
|
|
59
|
+
base_delay: DEFAULT_BASE_DELAY,
|
|
60
|
+
max_jitter: DEFAULT_MAX_JITTER,
|
|
61
|
+
max_pages: DEFAULT_MAX_PAGES
|
|
62
|
+
)
|
|
63
|
+
@base_url = normalize_url(base_url)
|
|
64
|
+
@timeout = timeout
|
|
65
|
+
@max_retries = max_retries
|
|
66
|
+
@base_delay = base_delay
|
|
67
|
+
@max_jitter = max_jitter
|
|
68
|
+
@max_pages = max_pages
|
|
69
|
+
|
|
70
|
+
unless @base_url == normalize_url(DEFAULT_BASE_URL) || localhost?(@base_url)
|
|
71
|
+
Fizzy::Security.require_https!(@base_url, "base URL")
|
|
72
|
+
end
|
|
73
|
+
validate!
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Creates a Config from environment variables.
|
|
77
|
+
#
|
|
78
|
+
# Environment variables:
|
|
79
|
+
# - FIZZY_BASE_URL: API base URL
|
|
80
|
+
# - FIZZY_TIMEOUT: Request timeout in seconds
|
|
81
|
+
# - FIZZY_MAX_RETRIES: Maximum retry attempts
|
|
82
|
+
#
|
|
83
|
+
# @return [Config]
|
|
84
|
+
def self.from_env
|
|
85
|
+
new(
|
|
86
|
+
base_url: ENV.fetch("FIZZY_BASE_URL", DEFAULT_BASE_URL),
|
|
87
|
+
timeout: ENV.fetch("FIZZY_TIMEOUT", DEFAULT_TIMEOUT).to_i,
|
|
88
|
+
max_retries: ENV.fetch("FIZZY_MAX_RETRIES", DEFAULT_MAX_RETRIES).to_i
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Loads configuration from a JSON file, with environment overrides.
|
|
93
|
+
#
|
|
94
|
+
# @param path [String] path to JSON config file
|
|
95
|
+
# @return [Config]
|
|
96
|
+
def self.from_file(path)
|
|
97
|
+
data = JSON.parse(File.read(path))
|
|
98
|
+
config = new(
|
|
99
|
+
base_url: data["base_url"] || DEFAULT_BASE_URL,
|
|
100
|
+
timeout: data["timeout"] || DEFAULT_TIMEOUT,
|
|
101
|
+
max_retries: data["max_retries"] || DEFAULT_MAX_RETRIES
|
|
102
|
+
)
|
|
103
|
+
config.load_from_env
|
|
104
|
+
config
|
|
105
|
+
rescue Errno::ENOENT
|
|
106
|
+
from_env
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Loads environment variable overrides into this config.
|
|
110
|
+
# @return [self]
|
|
111
|
+
def load_from_env
|
|
112
|
+
@base_url = normalize_url(ENV["FIZZY_BASE_URL"]) if ENV["FIZZY_BASE_URL"]
|
|
113
|
+
@timeout = ENV["FIZZY_TIMEOUT"].to_i if ENV["FIZZY_TIMEOUT"]
|
|
114
|
+
@max_retries = ENV["FIZZY_MAX_RETRIES"].to_i if ENV["FIZZY_MAX_RETRIES"]
|
|
115
|
+
Fizzy::Security.require_https!(@base_url, "base URL") unless localhost?(@base_url)
|
|
116
|
+
validate!
|
|
117
|
+
self
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Returns the default global config directory.
|
|
121
|
+
# @return [String]
|
|
122
|
+
def self.global_config_dir
|
|
123
|
+
config_dir = ENV["XDG_CONFIG_HOME"] || File.join(Dir.home, ".config")
|
|
124
|
+
File.join(config_dir, "fizzy")
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
def validate!
|
|
130
|
+
raise ArgumentError, "timeout must be positive" unless @timeout.is_a?(Numeric) && @timeout > 0
|
|
131
|
+
raise ArgumentError, "max_retries must be non-negative" unless @max_retries.is_a?(Integer) && @max_retries >= 0
|
|
132
|
+
raise ArgumentError, "max_pages must be positive" unless @max_pages.is_a?(Integer) && @max_pages > 0
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def normalize_url(url)
|
|
136
|
+
url&.chomp("/")
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def localhost?(url)
|
|
140
|
+
Fizzy::Security.localhost?(url)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fizzy
|
|
4
|
+
# Cookie-based authentication strategy.
|
|
5
|
+
# Sets the Cookie header with the session token for session-based auth
|
|
6
|
+
# (mobile/web clients).
|
|
7
|
+
#
|
|
8
|
+
# @example
|
|
9
|
+
# auth = Fizzy::CookieAuth.new("session_token_value")
|
|
10
|
+
# client = Fizzy.client(auth: auth)
|
|
11
|
+
class CookieAuth
|
|
12
|
+
include AuthStrategy
|
|
13
|
+
|
|
14
|
+
# @param session_token [String] the session token value
|
|
15
|
+
# @param cookie_name [String] the cookie name (defaults to "session_token")
|
|
16
|
+
def initialize(session_token, cookie_name: "session_token")
|
|
17
|
+
raise ArgumentError, "session_token cannot be nil or empty" if session_token.nil? || session_token.empty?
|
|
18
|
+
|
|
19
|
+
@session_token = session_token
|
|
20
|
+
@cookie_name = cookie_name
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def authenticate(headers)
|
|
24
|
+
headers["Cookie"] = "#{@cookie_name}=#{@session_token}"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
data/lib/fizzy/errors.rb
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
# Error types and codes for the Fizzy SDK.
|
|
6
|
+
module Fizzy
|
|
7
|
+
# Error codes for API responses
|
|
8
|
+
module ErrorCode
|
|
9
|
+
USAGE = "usage"
|
|
10
|
+
NOT_FOUND = "not_found"
|
|
11
|
+
AUTH = "auth_required"
|
|
12
|
+
FORBIDDEN = "forbidden"
|
|
13
|
+
RATE_LIMIT = "rate_limit"
|
|
14
|
+
NETWORK = "network"
|
|
15
|
+
API = "api_error"
|
|
16
|
+
AMBIGUOUS = "ambiguous"
|
|
17
|
+
VALIDATION = "validation"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Exit codes for CLI tools
|
|
21
|
+
module ExitCode
|
|
22
|
+
OK = 0
|
|
23
|
+
USAGE = 1
|
|
24
|
+
NOT_FOUND = 2
|
|
25
|
+
AUTH = 3
|
|
26
|
+
FORBIDDEN = 4
|
|
27
|
+
RATE_LIMIT = 5
|
|
28
|
+
NETWORK = 6
|
|
29
|
+
API = 7
|
|
30
|
+
AMBIGUOUS = 8
|
|
31
|
+
VALIDATION = 9
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Base error class for all Fizzy SDK errors.
|
|
35
|
+
# Provides structured error handling with codes, hints, and CLI exit codes.
|
|
36
|
+
#
|
|
37
|
+
# @example Catching errors
|
|
38
|
+
# begin
|
|
39
|
+
# client.boards.list
|
|
40
|
+
# rescue Fizzy::Error => e
|
|
41
|
+
# puts "#{e.code}: #{e.message}"
|
|
42
|
+
# puts "Hint: #{e.hint}" if e.hint
|
|
43
|
+
# exit e.exit_code
|
|
44
|
+
# end
|
|
45
|
+
class Error < StandardError
|
|
46
|
+
# @return [String] error category code
|
|
47
|
+
attr_reader :code
|
|
48
|
+
|
|
49
|
+
# @return [String, nil] user-friendly hint for resolving the error
|
|
50
|
+
attr_reader :hint
|
|
51
|
+
|
|
52
|
+
# @return [Integer, nil] HTTP status code that caused the error
|
|
53
|
+
attr_reader :http_status
|
|
54
|
+
|
|
55
|
+
# @return [Boolean] whether the operation can be retried
|
|
56
|
+
attr_reader :retryable
|
|
57
|
+
|
|
58
|
+
# @return [Integer, nil] seconds to wait before retrying (for rate limits)
|
|
59
|
+
attr_reader :retry_after
|
|
60
|
+
|
|
61
|
+
# @return [String, nil] X-Request-Id from the response
|
|
62
|
+
attr_reader :request_id
|
|
63
|
+
|
|
64
|
+
# @return [Exception, nil] original error that caused this error
|
|
65
|
+
attr_reader :cause
|
|
66
|
+
|
|
67
|
+
# @param code [String] error category code
|
|
68
|
+
# @param message [String] error message
|
|
69
|
+
# @param hint [String, nil] user-friendly hint
|
|
70
|
+
# @param http_status [Integer, nil] HTTP status code
|
|
71
|
+
# @param retryable [Boolean] whether operation can be retried
|
|
72
|
+
# @param retry_after [Integer, nil] seconds to wait before retry
|
|
73
|
+
# @param request_id [String, nil] X-Request-Id from response
|
|
74
|
+
# @param cause [Exception, nil] underlying cause
|
|
75
|
+
def initialize(code:, message:, hint: nil, http_status: nil, retryable: false, retry_after: nil, request_id: nil, cause: nil)
|
|
76
|
+
super(message)
|
|
77
|
+
@code = code
|
|
78
|
+
@hint = hint
|
|
79
|
+
@http_status = http_status
|
|
80
|
+
@retryable = retryable
|
|
81
|
+
@retry_after = retry_after
|
|
82
|
+
@request_id = request_id
|
|
83
|
+
@cause = cause
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Returns the exit code for CLI applications.
|
|
87
|
+
# @return [Integer]
|
|
88
|
+
def exit_code
|
|
89
|
+
self.class.exit_code_for(@code)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Returns whether this error can be retried.
|
|
93
|
+
# @return [Boolean]
|
|
94
|
+
def retryable?
|
|
95
|
+
@retryable
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Maps error codes to exit codes.
|
|
99
|
+
# @param code [String]
|
|
100
|
+
# @return [Integer]
|
|
101
|
+
def self.exit_code_for(code)
|
|
102
|
+
case code
|
|
103
|
+
when ErrorCode::USAGE then ExitCode::USAGE
|
|
104
|
+
when ErrorCode::NOT_FOUND then ExitCode::NOT_FOUND
|
|
105
|
+
when ErrorCode::AUTH then ExitCode::AUTH
|
|
106
|
+
when ErrorCode::FORBIDDEN then ExitCode::FORBIDDEN
|
|
107
|
+
when ErrorCode::RATE_LIMIT then ExitCode::RATE_LIMIT
|
|
108
|
+
when ErrorCode::NETWORK then ExitCode::NETWORK
|
|
109
|
+
when ErrorCode::API then ExitCode::API
|
|
110
|
+
when ErrorCode::AMBIGUOUS then ExitCode::AMBIGUOUS
|
|
111
|
+
when ErrorCode::VALIDATION then ExitCode::VALIDATION
|
|
112
|
+
else ExitCode::API
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Raised when there's a usage error (invalid arguments, missing config).
|
|
118
|
+
class UsageError < Error
|
|
119
|
+
def initialize(message, hint: nil)
|
|
120
|
+
super(code: ErrorCode::USAGE, message: message, hint: hint)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Raised when a resource is not found (404).
|
|
125
|
+
class NotFoundError < Error
|
|
126
|
+
def initialize(resource, identifier, hint: nil)
|
|
127
|
+
super(
|
|
128
|
+
code: ErrorCode::NOT_FOUND,
|
|
129
|
+
message: "#{resource} not found: #{identifier}",
|
|
130
|
+
hint: hint,
|
|
131
|
+
http_status: 404
|
|
132
|
+
)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Raised when authentication fails (401).
|
|
137
|
+
class AuthError < Error
|
|
138
|
+
def initialize(message = "Authentication required", hint: nil, cause: nil)
|
|
139
|
+
super(
|
|
140
|
+
code: ErrorCode::AUTH,
|
|
141
|
+
message: message,
|
|
142
|
+
hint: hint || "Check your access token or session cookie",
|
|
143
|
+
http_status: 401,
|
|
144
|
+
cause: cause
|
|
145
|
+
)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Raised when access is denied (403).
|
|
150
|
+
class ForbiddenError < Error
|
|
151
|
+
def initialize(message = "Access denied", hint: nil)
|
|
152
|
+
super(
|
|
153
|
+
code: ErrorCode::FORBIDDEN,
|
|
154
|
+
message: message,
|
|
155
|
+
hint: hint || "You do not have permission to access this resource",
|
|
156
|
+
http_status: 403
|
|
157
|
+
)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Raised when rate limited (429).
|
|
162
|
+
class RateLimitError < Error
|
|
163
|
+
def initialize(retry_after: nil, cause: nil)
|
|
164
|
+
hint = retry_after ? "Try again in #{retry_after} seconds" : "Please slow down requests"
|
|
165
|
+
super(
|
|
166
|
+
code: ErrorCode::RATE_LIMIT,
|
|
167
|
+
message: "Rate limit exceeded",
|
|
168
|
+
hint: hint,
|
|
169
|
+
http_status: 429,
|
|
170
|
+
retryable: true,
|
|
171
|
+
retry_after: retry_after,
|
|
172
|
+
cause: cause
|
|
173
|
+
)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Raised when there's a network error (connection, timeout, DNS).
|
|
178
|
+
class NetworkError < Error
|
|
179
|
+
def initialize(message = "Network error", cause: nil)
|
|
180
|
+
super(
|
|
181
|
+
code: ErrorCode::NETWORK,
|
|
182
|
+
message: message,
|
|
183
|
+
hint: cause&.message || "Check your network connection",
|
|
184
|
+
retryable: true,
|
|
185
|
+
cause: cause
|
|
186
|
+
)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Raised for generic API errors.
|
|
191
|
+
class APIError < Error
|
|
192
|
+
def initialize(message, http_status: nil, hint: nil, retryable: false, cause: nil)
|
|
193
|
+
super(
|
|
194
|
+
code: ErrorCode::API,
|
|
195
|
+
message: message,
|
|
196
|
+
hint: hint,
|
|
197
|
+
http_status: http_status,
|
|
198
|
+
retryable: retryable,
|
|
199
|
+
cause: cause
|
|
200
|
+
)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Creates an APIError from an HTTP status code.
|
|
204
|
+
# @param status [Integer] HTTP status code
|
|
205
|
+
# @param message [String, nil] optional error message
|
|
206
|
+
# @return [APIError]
|
|
207
|
+
def self.from_status(status, message = nil)
|
|
208
|
+
message ||= "Request failed (HTTP #{status})"
|
|
209
|
+
retryable = status >= 500 && status < 600
|
|
210
|
+
new(message, http_status: status, retryable: retryable)
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Raised when a name/identifier matches multiple resources.
|
|
215
|
+
class AmbiguousError < Error
|
|
216
|
+
# @return [Array<String>] list of matching resources
|
|
217
|
+
attr_reader :matches
|
|
218
|
+
|
|
219
|
+
def initialize(resource, matches: [])
|
|
220
|
+
@matches = matches
|
|
221
|
+
hint = if matches.any? && matches.length <= 5
|
|
222
|
+
"Did you mean: #{matches.join(", ")}"
|
|
223
|
+
else
|
|
224
|
+
"Be more specific"
|
|
225
|
+
end
|
|
226
|
+
super(
|
|
227
|
+
code: ErrorCode::AMBIGUOUS,
|
|
228
|
+
message: "Ambiguous #{resource}",
|
|
229
|
+
hint: hint
|
|
230
|
+
)
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Raised for validation errors (400, 422).
|
|
235
|
+
class ValidationError < Error
|
|
236
|
+
def initialize(message, hint: nil, http_status: 400)
|
|
237
|
+
super(
|
|
238
|
+
code: ErrorCode::VALIDATION,
|
|
239
|
+
message: message,
|
|
240
|
+
hint: hint,
|
|
241
|
+
http_status: http_status
|
|
242
|
+
)
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Maps an HTTP response to the appropriate error class.
|
|
247
|
+
#
|
|
248
|
+
# @param status [Integer] HTTP status code
|
|
249
|
+
# @param body [String, nil] response body (will attempt JSON parse)
|
|
250
|
+
# @param retry_after [Integer, nil] Retry-After header value
|
|
251
|
+
# @return [Error]
|
|
252
|
+
def self.error_from_response(status, body = nil, retry_after: nil)
|
|
253
|
+
message = parse_error_message(body) || "Request failed"
|
|
254
|
+
|
|
255
|
+
case status
|
|
256
|
+
when 400, 422
|
|
257
|
+
ValidationError.new(message, http_status: status)
|
|
258
|
+
when 401
|
|
259
|
+
AuthError.new(message)
|
|
260
|
+
when 403
|
|
261
|
+
ForbiddenError.new(message)
|
|
262
|
+
when 404
|
|
263
|
+
NotFoundError.new("Resource", "unknown")
|
|
264
|
+
when 429
|
|
265
|
+
RateLimitError.new(retry_after: retry_after)
|
|
266
|
+
when 500
|
|
267
|
+
APIError.new("Server error (500)", http_status: 500, retryable: true)
|
|
268
|
+
when 502, 503, 504
|
|
269
|
+
APIError.new("Gateway error (#{status})", http_status: status, retryable: true)
|
|
270
|
+
else
|
|
271
|
+
APIError.from_status(status, message)
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Parses error message from response body.
|
|
276
|
+
# @param body [String, nil]
|
|
277
|
+
# @return [String, nil]
|
|
278
|
+
def self.parse_error_message(body)
|
|
279
|
+
return nil if body.nil? || body.empty?
|
|
280
|
+
|
|
281
|
+
# Guard against oversized error bodies before parsing
|
|
282
|
+
Fizzy::Security.check_body_size!(body, Fizzy::Security::MAX_ERROR_BODY_BYTES, "Error")
|
|
283
|
+
|
|
284
|
+
data = JSON.parse(body)
|
|
285
|
+
msg = data["error"] || data["message"]
|
|
286
|
+
msg ? Fizzy::Security.truncate(msg) : nil
|
|
287
|
+
rescue JSON::ParserError, Fizzy::APIError
|
|
288
|
+
# Return nil on parse errors or oversized bodies to preserve normal error type mapping
|
|
289
|
+
nil
|
|
290
|
+
end
|
|
291
|
+
end
|