mixin_bot 2.2.2 → 2.3.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: 5a607209836b8c47fbcff727229f7ab93940f9b1dc5a99d4ab307345ffa952a0
4
- data.tar.gz: aae920ac21d384f0a978b7e4a07e20df5c55cc6016cd7fbce1fa5231c638b742
3
+ metadata.gz: 419e39522e8235c79d12c0506cb4529d34ed130170acc3805fd5827eb63e7428
4
+ data.tar.gz: f47598cf481cd07be7ca023c329018320a63b0b111a5ba4c68b646c8bfa2819d
5
5
  SHA512:
6
- metadata.gz: 46937ef32ab9ada8e3218c682fbe36a4086f7688ed5cf21ed5ad5aab575f9e8d8fe65c45220748021194500b486a538a06ddaa4d653af6762d65744c0bff51f8
7
- data.tar.gz: 4a6d552a0fc8476105cf553b79ab1f2c231db95acc4cedec9131cc805758c1a68fd9845bc819f5396a42c297b3caf20f119a57e9cd066ab37d7f1cb7de04edb5
6
+ metadata.gz: 20330faef09d018e2c1e914a3766270648c681d79d633fefd633b6880ff40542f9b730df77ff267974507d25ebdc107bfd5005cacc15f5f0c3968c3cd4cf824f
7
+ data.tar.gz: db4cfbd8fd5520d076927326316ebf07682c7065597478d8892e8f94c8a13f72a17d6e3f603f1770f6a66a61fb2c1cf17562060f6c4b9f596c0b67de1baa5031
data/CHANGELOG.md CHANGED
@@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.3.0] - 2026-05-27
11
+
12
+ ### Added
13
+
14
+ - **`MixinBot::APIError`** — structured base for API failures with `code`, `description`, `request_id`, `extra`, and helpers `retryable?`, `throttle?`, `client_error?`.
15
+ - **Typed API errors** — `RateLimitError`, `ValidationError`, `ConflictError`, `TransferError`, `TransientError`, `AppUpdateRequiredError`, `InvalidAddressFormatError`, `ServerError`.
16
+ - **`MixinBot.retryable?(error)`** — canonical retry policy for network timeouts, server errors (500+), and transient codes (10104, 10105).
17
+ - **CLI** — structured error kind `rate_limit`; API errors in JSON output include `code`, `request_id`, and `throttle` when available.
18
+ - **`test/mixin_bot/client/test_error_mapper.rb`** — unit tests for full official error-code catalog, legacy codes, HTTP fallbacks, and retry/throttle semantics.
19
+
20
+ ### Changed
21
+
22
+ - **BREAKING: Error code mapping** — aligned to [Mixin API error codes](https://developers.mixin.one/docs/api/error-codes). Code `429` raises `RateLimitError` (not `ForbiddenError`). Codes `10002` and `20116` raise `ValidationError` and `ConflictError` respectively. `ForbiddenError` is reserved for code `403`.
23
+ - **`Monitor.check_retryable_error`** — delegates to `MixinBot.retryable?` (no longer retries on `"insufficient"` message substrings).
24
+ - **`Client#parse_response!`** — HTTP status fallback for 401/403/429/5xx when JSON `error` is absent (CDN/proxy edge cases).
25
+
26
+ ### Migration
27
+
28
+ - Rescue rate limits explicitly: `rescue MixinBot::RateLimitError` (or check `error.throttle?`).
29
+ - Replace string-based retry heuristics with `MixinBot.retryable?(error)`.
30
+ - If you rescued `ForbiddenError` expecting throttling, update to handle `RateLimitError` separately.
31
+
10
32
  ## [2.2.2] - 2026-05-24
11
33
 
12
34
  ### Fixed
data/README.md CHANGED
@@ -194,7 +194,11 @@ Top-level helpers on **`MixinBot::API`**: `access_token`, `encode_raw_transactio
194
194
 
195
195
  ### Errors
196
196
 
197
- Custom errors under `MixinBot::` include `ResponseError`, `UnauthorizedError`, `InsufficientBalanceError`, `UtxoInsufficientError`, `PinError`, and `InvalidInvoiceFormatError`. See `lib/mixin_bot/errors.rb`.
197
+ API failures raise structured `MixinBot::APIError` subclasses with `code`, `description`, `request_id`, and behavior helpers (`retryable?`, `throttle?`). The full mapping follows the [Mixin error codes](https://developers.mixin.one/docs/api/error-codes) table — notably `429` `RateLimitError`, not `ForbiddenError`.
198
+
199
+ Use `MixinBot.retryable?(error)` for job retry decisions; use `error.throttle?` on `RateLimitError` for global backoff. `Monitor.check_retryable_error` delegates to the same policy.
200
+
201
+ Local validation errors (`ArgumentError`, `InvalidInvoiceFormatError`, …) and `InsufficientAppBillingError` (billing preflight) are separate from API envelope errors. See `lib/mixin_bot/errors.rb`.
198
202
 
199
203
  ### Multiple bots
200
204
 
data/docs/agent/cli.md CHANGED
@@ -72,7 +72,24 @@ Error (stderr, exit 1):
72
72
  }
73
73
  ```
74
74
 
75
- Error kinds: `invalid_args`, `auth`, `not_found`, `api_error`, `billing`, `unsupported`, `conflict`, `internal`.
75
+ API failures may also include `code`, `request_id`, and `throttle` (true for rate limits):
76
+
77
+ ```json
78
+ {
79
+ "status": "error",
80
+ "error": {
81
+ "kind": "rate_limit",
82
+ "message": "GET | /me | {}, errcode: 429, ...",
83
+ "code": 429,
84
+ "request_id": "abc-123",
85
+ "throttle": true
86
+ }
87
+ }
88
+ ```
89
+
90
+ Error kinds: `invalid_args`, `auth`, `not_found`, `rate_limit`, `api_error`, `billing`, `unsupported`, `conflict`, `internal`.
91
+
92
+ For retry logic in automation, prefer `MixinBot.retryable?(exception)` in Ruby; CLI kinds mark `rate_limit` as non-retryable (use global backoff when `throttle` is true).
76
93
 
77
94
  ## Commands
78
95
 
@@ -9,6 +9,7 @@ module MixinBot
9
9
  invalid_args: { retryable: false, description: 'Invalid or missing arguments' },
10
10
  auth: { retryable: false, description: 'Authentication or authorization failed' },
11
11
  not_found: { retryable: false, description: 'Requested resource was not found' },
12
+ rate_limit: { retryable: false, description: 'API rate limit exceeded; slow down globally' },
12
13
  api_error: { retryable: false, description: 'Mixin API returned an error' },
13
14
  unsupported: { retryable: false, description: 'Operation is not supported in this context' },
14
15
  conflict: { retryable: false, description: 'Resource exists with incompatible configuration' },
@@ -30,16 +31,19 @@ module MixinBot
30
31
 
31
32
  def kind_for_exception(error)
32
33
  case error
33
- when MixinBot::ArgumentError, ::ArgumentError
34
+ when MixinBot::ArgumentError, ::ArgumentError, ValidationError, InvalidAddressFormatError
34
35
  :invalid_args
36
+ when RateLimitError
37
+ :rate_limit
35
38
  when UnauthorizedError, ForbiddenError, PinError, ConfigurationNotValidError
36
39
  :auth
37
40
  when NotFoundError, UserNotFoundError
38
41
  :not_found
39
42
  when InsufficientAppBillingError
40
43
  :billing
41
- when ResponseError, RequestError, HttpError,
42
- InsufficientBalanceError, UtxoInsufficientError, InsufficientPoolError
44
+ when ResponseError, RequestError, HttpError, ServerError,
45
+ InsufficientBalanceError, UtxoInsufficientError, InsufficientPoolError,
46
+ ConflictError, TransferError, TransientError, AppUpdateRequiredError
43
47
  :api_error
44
48
  else
45
49
  :internal
@@ -50,6 +54,7 @@ module MixinBot
50
54
  msg = message.to_s.downcase
51
55
  return :auth if msg.include?('unauthorized') || msg.include?('authentication')
52
56
  return :not_found if msg.include?('not found') || msg.include?('404')
57
+ return :rate_limit if msg.include?('too many requests') || msg.include?('errcode: 429')
53
58
  return :unsupported if msg.include?('unsupported') || msg.include?('not supported')
54
59
  return :invalid_args if msg.include?('invalid') || msg.include?('unknown')
55
60
 
@@ -84,6 +84,11 @@ module MixinBot
84
84
  }
85
85
  }
86
86
  error_body['error']['hint'] = hint if hint.present?
87
+ if exception.is_a?(MixinBot::APIError)
88
+ error_body['error']['code'] = exception.code unless exception.code.nil?
89
+ error_body['error']['request_id'] = exception.request_id if exception.request_id.present?
90
+ error_body['error']['throttle'] = true if exception.respond_to?(:throttle?) && exception.throttle?
91
+ end
87
92
  warn(JSON.generate(error_body))
88
93
  else
89
94
  warn(format_error(message))
@@ -6,34 +6,54 @@ module MixinBot
6
6
  # Maps Mixin API +error+ objects to Ruby exceptions.
7
7
  #
8
8
  module ErrorMapper
9
+ CODE_MAP = {
10
+ 400 => ValidationError,
11
+ 401 => UnauthorizedError,
12
+ 403 => ForbiddenError,
13
+ 404 => NotFoundError,
14
+ 429 => RateLimitError,
15
+ 10_002 => ValidationError,
16
+ 10_006 => AppUpdateRequiredError,
17
+ 10_104 => TransientError,
18
+ 10_105 => TransientError,
19
+ 10_404 => UserNotFoundError,
20
+ 20_116 => ConflictError,
21
+ 20_117 => InsufficientBalanceError,
22
+ 20_118 => PinError,
23
+ 20_119 => PinError,
24
+ 20_120 => TransferError,
25
+ 20_121 => UnauthorizedError,
26
+ 20_123 => ConflictError,
27
+ 20_124 => InsufficientBalanceError,
28
+ 20_125 => ConflictError,
29
+ 20_127 => TransferError,
30
+ 20_131 => ValidationError,
31
+ 20_133 => ConflictError,
32
+ 20_134 => TransferError,
33
+ 20_135 => TransferError,
34
+ 20_150 => ValidationError,
35
+ 30_102 => InvalidAddressFormatError,
36
+ 30_103 => InsufficientPoolError,
37
+ 500 => ServerError,
38
+ 7000 => ServerError,
39
+ 7001 => ServerError
40
+ }.freeze
41
+
9
42
  module_function
10
43
 
11
44
  def raise_for!(verb:, path:, body:, response:, result:)
12
45
  err = result['error'] || {}
13
- code = err['code']
14
- desc = err['description']
15
- req_id = response&.headers&.[]('X-Request-Id')
16
- srv_time = response&.headers&.[]('X-Server-Time')
17
- errmsg = "#{verb.upcase} | #{path} | #{body}, errcode: #{code}, errmsg: #{desc}, request_id: #{req_id}, server_time: #{srv_time}"
46
+ code = err['code']&.to_i
47
+ klass = CODE_MAP[code] || default_class_for_code(code)
48
+ raise APIError.build(klass, verb:, path:, body:, response:, result:)
49
+ end
50
+
51
+ def build(klass, verb:, path:, body:, response:, result:, **)
52
+ APIError.build(klass, verb:, path:, body:, response:, result:, **)
53
+ end
18
54
 
19
- case code
20
- when 401, 20_121
21
- raise UnauthorizedError, errmsg
22
- when 403, 20_116, 10_002, 429
23
- raise ForbiddenError, errmsg
24
- when 404
25
- raise NotFoundError, errmsg
26
- when 20_117
27
- raise InsufficientBalanceError, errmsg
28
- when 20_118, 20_119
29
- raise PinError, errmsg
30
- when 30_103
31
- raise InsufficientPoolError, errmsg
32
- when 10_404
33
- raise UserNotFoundError, errmsg
34
- else
35
- raise ResponseError, errmsg
36
- end
55
+ def default_class_for_code(_code)
56
+ ResponseError
37
57
  end
38
58
  end
39
59
  end
@@ -125,9 +125,30 @@ module MixinBot
125
125
 
126
126
  def parse_response!(verb:, path:, body:, response:)
127
127
  result = response.body
128
- return MixinBot::Models::ApiEnvelope.new(result) if result['error'].blank?
128
+ result = {} unless result.is_a?(Hash)
129
+
130
+ if result['error'].blank?
131
+ raise_http_status_error!(verb:, path:, body:, response:) if http_error_status?(response.status)
132
+ return MixinBot::Models::ApiEnvelope.new(result)
133
+ end
129
134
 
130
135
  ErrorMapper.raise_for!(verb:, path:, body:, response:, result:)
131
136
  end
137
+
138
+ def http_error_status?(status)
139
+ [401, 403, 429].include?(status) || status >= 500
140
+ end
141
+
142
+ def raise_http_status_error!(verb:, path:, body:, response:)
143
+ klass =
144
+ case response.status
145
+ when 429 then RateLimitError
146
+ when 401 then UnauthorizedError
147
+ when 403 then ForbiddenError
148
+ else ServerError
149
+ end
150
+
151
+ raise ErrorMapper.build(klass, verb:, path:, body:, response:, result: {})
152
+ end
132
153
  end
133
154
  end
@@ -7,7 +7,7 @@ module MixinBot
7
7
  class Error < StandardError; end
8
8
 
9
9
  ##
10
- # Raised when invalid arguments are provided.
10
+ # Raised when invalid arguments are provided (local validation, not API code 400).
11
11
  #
12
12
  class ArgumentError < StandardError; end
13
13
 
@@ -22,34 +22,196 @@ module MixinBot
22
22
  class RequestError < Error; end
23
23
 
24
24
  ##
25
- # Raised when Mixin API returns an error response.
25
+ # Base class for Mixin API error responses with structured metadata.
26
26
  #
27
- class ResponseError < Error; end
27
+ class APIError < Error
28
+ attr_reader :code, :description, :status, :http_status, :request_id, :server_time,
29
+ :retry_after, :extra, :path, :verb, :body
30
+
31
+ # rubocop:disable Metrics/ParameterLists -- structured API error metadata
32
+ def initialize(message = nil, code: nil, description: nil, status: nil, http_status: nil,
33
+ request_id: nil, server_time: nil, retry_after: nil, extra: nil,
34
+ path: nil, verb: nil, body: nil)
35
+ @code = code&.to_i
36
+ @description = description
37
+ @status = status&.to_i
38
+ @http_status = http_status&.to_i
39
+ @request_id = request_id
40
+ @server_time = server_time
41
+ @retry_after = retry_after
42
+ @extra = extra
43
+ @path = path
44
+ @verb = verb
45
+ @body = body
46
+ super(message || formatted_message)
47
+ end
48
+ # rubocop:enable Metrics/ParameterLists
49
+
50
+ def client_error?
51
+ c = code.to_i
52
+ return true if c.between?(400, 499)
53
+ return true if http_status == 202 && c.positive? && c < 500
54
+
55
+ false
56
+ end
57
+
58
+ def retryable?
59
+ false
60
+ end
61
+
62
+ def throttle?
63
+ false
64
+ end
65
+
66
+ def formatted_message
67
+ format(
68
+ '%<verb>s | %<path>s | %<body>s, errcode: %<code>s, errmsg: %<description>s, ' \
69
+ 'request_id: %<request_id>s, server_time: %<server_time>s',
70
+ verb: verb.to_s.upcase,
71
+ path: path.to_s,
72
+ body: body.to_s,
73
+ code: code,
74
+ description: description,
75
+ request_id: request_id,
76
+ server_time: server_time
77
+ )
78
+ end
79
+
80
+ class << self
81
+ def build(klass, verb:, path:, body:, response:, result:, code: nil, description: nil)
82
+ err = result.is_a?(Hash) ? (result['error'] || {}) : {}
83
+ resolved_code = code || err['code'] || infer_code_from_http(response)
84
+ resolved_description = description || err['description'] || http_status_description(response&.status)
85
+ headers = response&.headers || {}
86
+ retry_after = headers['Retry-After'] if klass <= RateLimitError
87
+
88
+ klass.new(
89
+ code: resolved_code,
90
+ description: resolved_description,
91
+ status: err['status'],
92
+ http_status: response&.status,
93
+ request_id: headers['X-Request-Id'],
94
+ server_time: headers['X-Server-Time'],
95
+ retry_after: retry_after,
96
+ extra: err['extra'],
97
+ path: path,
98
+ verb: verb,
99
+ body: body
100
+ )
101
+ end
102
+
103
+ private
104
+
105
+ def infer_code_from_http(response)
106
+ return nil unless response
107
+
108
+ case response.status
109
+ when 401 then 401
110
+ when 403 then 403
111
+ when 429 then 429
112
+ else
113
+ response.status if response.status >= 500
114
+ end
115
+ end
116
+
117
+ def http_status_description(status)
118
+ case status
119
+ when 401 then 'Unauthorized'
120
+ when 403 then 'Forbidden'
121
+ when 429 then 'Too Many Requests'
122
+ when 500.. then 'Internal Server Error'
123
+ else
124
+ "HTTP #{status}"
125
+ end
126
+ end
127
+ end
128
+ end
28
129
 
29
130
  ##
30
- # Raised when a requested resource is not found (HTTP 404).
131
+ # Raised when Mixin API returns an error response (unmapped or generic codes).
31
132
  #
32
- class NotFoundError < Error; end
133
+ class ResponseError < APIError
134
+ def retryable?
135
+ code.to_i >= 500
136
+ end
137
+ end
138
+
139
+ ##
140
+ # Raised when a requested resource is not found (error code 404).
141
+ #
142
+ class NotFoundError < APIError; end
33
143
 
34
144
  ##
35
145
  # Raised when a user is not found (error code 10404).
36
146
  #
37
- class UserNotFoundError < Error; end
147
+ class UserNotFoundError < APIError; end
148
+
149
+ ##
150
+ # Raised when authentication fails (error codes 401, 20121).
151
+ #
152
+ class UnauthorizedError < APIError; end
153
+
154
+ ##
155
+ # Raised when access is forbidden (error code 403).
156
+ #
157
+ class ForbiddenError < APIError; end
158
+
159
+ ##
160
+ # Raised when the API rate limit is exceeded (error code 429).
161
+ #
162
+ class RateLimitError < APIError
163
+ def throttle?
164
+ true
165
+ end
166
+ end
167
+
168
+ ##
169
+ # Raised when request data is invalid (error codes 400, 10002, 20131, 20150).
170
+ #
171
+ class ValidationError < APIError; end
172
+
173
+ ##
174
+ # Raised when a resource conflict or capacity limit applies (error codes 20116, 20123, 20125, 20133).
175
+ #
176
+ class ConflictError < APIError; end
177
+
178
+ ##
179
+ # Raised for transfer/withdraw amount or fee constraints.
180
+ #
181
+ class TransferError < APIError; end
182
+
183
+ ##
184
+ # Raised for transient conditions that may succeed on retry (error codes 10104, 10105).
185
+ #
186
+ class TransientError < APIError
187
+ def retryable?
188
+ true
189
+ end
190
+ end
38
191
 
39
192
  ##
40
- # Raised when authentication fails (HTTP 401).
193
+ # Raised when the app must be updated (error code 10006).
41
194
  #
42
- class UnauthorizedError < Error; end
195
+ class AppUpdateRequiredError < APIError; end
43
196
 
44
197
  ##
45
- # Raised when access is forbidden (HTTP 403).
198
+ # Raised when an address format is invalid (error code 30102).
46
199
  #
47
- class ForbiddenError < Error; end
200
+ class InvalidAddressFormatError < APIError; end
48
201
 
49
202
  ##
50
- # Raised when there is insufficient balance for a transaction (error code 20117).
203
+ # Raised for server-side failures (error codes 500, 7000, 7001).
51
204
  #
52
- class InsufficientBalanceError < Error; end
205
+ class ServerError < APIError
206
+ def retryable?
207
+ true
208
+ end
209
+ end
210
+
211
+ ##
212
+ # Raised when there is insufficient balance for a transaction (error codes 20117, 20124).
213
+ #
214
+ class InsufficientBalanceError < APIError; end
53
215
 
54
216
  ##
55
217
  # Raised when app prepaid billing credit lacks headroom for a billed operation.
@@ -77,8 +239,8 @@ module MixinBot
77
239
  class UtxoInsufficientError < InsufficientBalanceError
78
240
  attr_reader :total_input, :total_output, :output_size
79
241
 
80
- def initialize(message, total_input: nil, total_output: nil, output_size: nil)
81
- super(message)
242
+ def initialize(message = nil, total_input: nil, total_output: nil, output_size: nil, **)
243
+ super(message, **)
82
244
  @total_input = total_input
83
245
  @total_output = total_output
84
246
  @output_size = output_size
@@ -88,12 +250,12 @@ module MixinBot
88
250
  ##
89
251
  # Raised when there is insufficient pool for a transaction (error code 30103).
90
252
  #
91
- class InsufficientPoolError < Error; end
253
+ class InsufficientPoolError < APIError; end
92
254
 
93
255
  ##
94
256
  # Raised when PIN verification fails (error codes 20118, 20119).
95
257
  #
96
- class PinError < Error; end
258
+ class PinError < APIError; end
97
259
 
98
260
  ##
99
261
  # Raised when NFO memo format is invalid.
@@ -119,4 +281,16 @@ module MixinBot
119
281
  # Raised when invoice format is invalid.
120
282
  #
121
283
  class InvalidInvoiceFormatError < Error; end
284
+
285
+ class << self
286
+ ##
287
+ # Canonical retry decision for API and network failures.
288
+ #
289
+ def retryable?(error)
290
+ return true if error.is_a?(Faraday::TimeoutError) || error.is_a?(Faraday::ConnectionFailed)
291
+ return error.retryable? if error.respond_to?(:retryable?)
292
+
293
+ false
294
+ end
295
+ end
122
296
  end
@@ -61,16 +61,7 @@ module MixinBot
61
61
  end
62
62
 
63
63
  def check_retryable_error(error)
64
- return false if error.nil?
65
-
66
- reason = error.message.to_s.downcase
67
- return true if reason.include?('timeout')
68
- return true if reason.include?('internal server')
69
- return true if reason.include?('insufficient')
70
- return true if reason.include?('inputs locked by')
71
- return true if reason.include?('by other transaction')
72
-
73
- false
64
+ MixinBot.retryable?(error)
74
65
  end
75
66
  end
76
67
  end
@@ -11,5 +11,5 @@ module MixinBot
11
11
  #
12
12
  # @see https://semver.org/
13
13
  #
14
- VERSION = '2.2.2'
14
+ VERSION = '2.3.0'
15
15
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mixin_bot
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.2.2
4
+ version: 2.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - an-lee
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-05-24 00:00:00.000000000 Z
11
+ date: 2026-05-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport