mercadopago-sdk 3.2.1 → 3.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: 7dfcf7186c9d72f9b02200699acf4807a80aee9014b0c8b1905097850b6ed5fe
4
- data.tar.gz: 3cb25341c72de1d3686fe217c7fb81e3ec0bfa87a74cf80cee163b1c3ec6a0d6
3
+ metadata.gz: 518cbc2c53d00de1452bf210afff62915ba381a970d084ca42a848e5604fa582
4
+ data.tar.gz: 85468b9a03305eb33e4b053a4aae7149cbacd1ceb33df494e25bf63391b2bfea
5
5
  SHA512:
6
- metadata.gz: c446ae477af4b97192b150ac3618b2e363d0d98dc8cb060aa08a26f4dc13811612fa69d6f618180fcd315f77cdd83757872f4c052b6689a86ab533411a7fd8bb
7
- data.tar.gz: '086a6f76e544b588f0f09a55d59946bd68f9eb373dbd3328841fe977168273e2b5a2d35d9e30cf0b9672d81fd31947b8fcdc1bfaca17d21a1b6529e55adbad7b'
6
+ metadata.gz: 59839cc98cb80e2718b5694f05559b0a73fe23f04b953c0d172bb80b0609c220f673bd88952326eab551bcd52edd0beec0cb2173c188b9ea9cace45c5513b75b
7
+ data.tar.gz: 4e2e68a2fef69a84eafba9c8fa6b877d214f953e764d26525f48dca5cb812686195fe7fea80085704c8827de1d023fe385c366dede92156a98e680d51ae8dbce
@@ -13,13 +13,13 @@ jobs:
13
13
 
14
14
  steps:
15
15
  - name: Checkout code
16
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
16
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
17
17
  with:
18
18
  ref: ${{ github.event.pull_request.head.ref }}
19
19
  repository: ${{ github.event.pull_request.head.repo.full_name }}
20
20
 
21
21
  - name: Cache gems
22
- uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v6
22
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
23
23
  with:
24
24
  path: vendor/bundle
25
25
  key: ${{ runner.os }}-gems-${{ hashFiles('**/Gemfile.lock') }}
@@ -14,7 +14,7 @@ jobs:
14
14
 
15
15
  steps:
16
16
  - name: Checkout code
17
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
17
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
18
18
 
19
19
  - name: Install dependencies
20
20
  run: bundle install
data/.rubocop.yml CHANGED
@@ -1 +1,9 @@
1
1
  inherit_from: .rubocop_todo.yml
2
+
3
+ Metrics/AbcSize: {Max: 32}
4
+ Metrics/CyclomaticComplexity: {Max: 18}
5
+ Metrics/ParameterLists: {Max: 12}
6
+ Metrics/PerceivedComplexity: {Max: 18}
7
+ Style/ExplicitBlockArgument: {Enabled: false}
8
+ Style/NumericPredicate: {Enabled: false}
9
+ Style/TrivialAccessors: {Enabled: false}
data/CHANGELOG.md CHANGED
@@ -5,6 +5,28 @@ All notable changes to this project will be documented in this file.
5
5
  This project follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
6
6
  and [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [3.3.0] - 2026-08-04
9
+
10
+ ### Added
11
+
12
+ - **SDK ergonomics**: typed exceptions, configurable retry, and auto-pagination ([#162](https://github.com/mercadopago/sdk-ruby/pull/162))
13
+ - `MercadoPago::ApiError` now has 12 specific subtypes per HTTP status code
14
+ - Request options gain optional `max_retries`, `retry_on`, `initial_delay_ms`, `max_delay_ms` and `on_retry` callback
15
+ - New auto-pagination support on search endpoints
16
+ - **Missing API methods** — `disbursement_refund.list`, `advanced_payment.update`, `customer_card.update`, `payment.update` ([#161](https://github.com/mercadopago/sdk-ruby/pull/161))
17
+ - **CREDENTIAL_ON_FILE messaging fields** on Payment types ([#158](https://github.com/mercadopago/sdk-ruby/pull/158)): `first_transaction`, `storage`, `transaction_initiator`, `reference`
18
+
19
+ ### Fixed
20
+
21
+ - Webhook `tolerance_seconds` unit mismatch — `ts` header value compared in seconds against a millisecond clock ([#163](https://github.com/mercadopago/sdk-ruby/pull/163))
22
+ - `constant_time_equals` error on multibyte v1 hash ([#163](https://github.com/mercadopago/sdk-ruby/pull/163))
23
+
24
+ ### Dependencies
25
+
26
+ - Bump `json` gem ([#159](https://github.com/mercadopago/sdk-ruby/pull/159))
27
+ - Bump `actions/cache` to `v6.1.0` ([#160](https://github.com/mercadopago/sdk-ruby/pull/160))
28
+ - Bump `actions/checkout` to `v7.0.1` ([#157](https://github.com/mercadopago/sdk-ruby/pull/157))
29
+
8
30
  ## [3.2.0] - 2026-06-30
9
31
 
10
32
  ### Added
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- mercadopago-sdk (3.2.1)
4
+ mercadopago-sdk (3.3.0)
5
5
  faraday (~> 2.0)
6
6
  json (~> 2.5)
7
7
 
@@ -17,7 +17,7 @@ GEM
17
17
  faraday-net_http (3.4.4)
18
18
  net-http (~> 0.5)
19
19
  io-console (0.8.2)
20
- json (2.21.1)
20
+ json (2.21.2)
21
21
  language_server-protocol (3.17.0.6)
22
22
  lint_roller (1.1.0)
23
23
  logger (1.7.0)
@@ -0,0 +1,194 @@
1
+ # Example: Automatic Payments with CREDENTIAL_ON_FILE (COF).
2
+ #
3
+ # CREDENTIAL_ON_FILE replaces the deprecated SUBSCRIPTIONS type for recurring
4
+ # and unscheduled automatic payments. It covers three main scenarios:
5
+ #
6
+ # 1. CIT – Customer-Initiated Transaction (enrollment / first charge):
7
+ # The customer actively authorizes the initial payment and consents to
8
+ # storing their credentials for future merchant-initiated charges.
9
+ #
10
+ # 2. MIT – Merchant-Initiated Transaction (recurring charge):
11
+ # Subsequent charges triggered by the merchant without customer
12
+ # interaction (e.g. monthly subscription billing).
13
+ #
14
+ # 3. UCOF-CIT – Unscheduled COF Customer-Initiated Transaction:
15
+ # A one-off purchase where the customer uses previously stored
16
+ # credentials, but the amount / date was not pre-agreed.
17
+ #
18
+ # Reference: https://www.mercadopago.com/developers/en/reference
19
+
20
+ require_relative '../../lib/mercadopago'
21
+
22
+ sdk = Mercadopago::SDK.new('<ACCESS_TOKEN>')
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # 1. CIT — Customer-Initiated Transaction (enrollment)
26
+ #
27
+ # The customer completes the first payment and grants permission to store
28
+ # their card credentials for future recurring charges.
29
+ # ---------------------------------------------------------------------------
30
+ def create_cit_payment(sdk)
31
+ request = {
32
+ transaction_amount: 100.00,
33
+ token: '<CARD_TOKEN>',
34
+ description: 'Monthly subscription — enrollment',
35
+ installments: 1,
36
+ payment_method_id: '<PAYMENT_METHOD_ID>',
37
+ payer: {
38
+ email: '<PAYER_EMAIL>',
39
+ identification: {
40
+ type: '<ID_TYPE>',
41
+ number: '<ID_NUMBER>'
42
+ }
43
+ },
44
+ # CREDENTIAL_ON_FILE block for the enrollment (first) transaction.
45
+ point_of_interaction: {
46
+ linked_to: 'subscription',
47
+ transaction_data: {
48
+ # type: "CREDENTIAL_ON_FILE" — new credential-on-file payment type.
49
+ type: 'CREDENTIAL_ON_FILE',
50
+ # sub_type: "recurring" — charge is part of a recurring series.
51
+ sub_type: 'recurring',
52
+ # storage: "store" — instructs the processor to persist the credentials.
53
+ storage: 'store',
54
+ # transaction_initiator: "customer" — the customer triggered this charge.
55
+ transaction_initiator: 'customer',
56
+ # first_transaction: true — marks this as the enrollment / anchor payment.
57
+ first_transaction: true
58
+ }
59
+ }
60
+ }
61
+
62
+ custom_headers = {
63
+ 'X-Idempotency-Key': '<SOME_UNIQUE_VALUE>'
64
+ }
65
+ custom_request_options = Mercadopago::RequestOptions.new(custom_headers: custom_headers)
66
+
67
+ result = sdk.payment.create(request, request_options: custom_request_options)
68
+ payment = result[:response]
69
+ puts "CIT payment created. ID: #{payment['id']}, status: #{payment['status']}"
70
+ payment['id'] # return payment ID to use as reference in subsequent MITs
71
+ rescue MercadoPago::MPApiException => e
72
+ puts "Status code: #{e.api_response.status_code}"
73
+ puts "Content: #{e.api_response.content}"
74
+ rescue StandardError => e
75
+ puts e.message
76
+ end
77
+
78
+ # ---------------------------------------------------------------------------
79
+ # 2. MIT — Merchant-Initiated Transaction (recurring monthly charge)
80
+ #
81
+ # The merchant charges the customer without customer interaction, using the
82
+ # credentials stored during the CIT enrollment. The CIT payment ID is
83
+ # provided as a reference to the original agreement.
84
+ # ---------------------------------------------------------------------------
85
+ def create_mit_payment(sdk, cit_payment_id)
86
+ request = {
87
+ transaction_amount: 100.00,
88
+ token: '<CARD_TOKEN>',
89
+ description: 'Monthly subscription — recurring charge',
90
+ installments: 1,
91
+ payment_method_id: '<PAYMENT_METHOD_ID>',
92
+ payer: {
93
+ email: '<PAYER_EMAIL>',
94
+ identification: {
95
+ type: '<ID_TYPE>',
96
+ number: '<ID_NUMBER>'
97
+ }
98
+ },
99
+ # CREDENTIAL_ON_FILE block for merchant-initiated recurring charges.
100
+ point_of_interaction: {
101
+ linked_to: 'subscription',
102
+ transaction_data: {
103
+ # type: "CREDENTIAL_ON_FILE" — same type as the enrollment.
104
+ type: 'CREDENTIAL_ON_FILE',
105
+ # sub_type: "recurring" — charge belongs to a recurring series.
106
+ sub_type: 'recurring',
107
+ # storage: "stored" — credentials were already stored in a prior CIT.
108
+ storage: 'stored',
109
+ # transaction_initiator: "merchant" — the merchant triggered this charge.
110
+ transaction_initiator: 'merchant',
111
+ # first_transaction: false — not the enrollment; uses stored credentials.
112
+ first_transaction: false,
113
+ # reference.id — ID of the CIT payment that anchors this agreement.
114
+ reference: {
115
+ id: cit_payment_id.to_s
116
+ }
117
+ }
118
+ }
119
+ }
120
+
121
+ custom_headers = {
122
+ 'X-Idempotency-Key': '<SOME_UNIQUE_VALUE>'
123
+ }
124
+ custom_request_options = Mercadopago::RequestOptions.new(custom_headers: custom_headers)
125
+
126
+ result = sdk.payment.create(request, request_options: custom_request_options)
127
+ payment = result[:response]
128
+ puts "MIT payment created. ID: #{payment['id']}, status: #{payment['status']}"
129
+ rescue MercadoPago::MPApiException => e
130
+ puts "Status code: #{e.api_response.status_code}"
131
+ puts "Content: #{e.api_response.content}"
132
+ rescue StandardError => e
133
+ puts e.message
134
+ end
135
+
136
+ # ---------------------------------------------------------------------------
137
+ # 3. UCOF-CIT — Unscheduled COF Customer-Initiated Transaction
138
+ #
139
+ # The customer actively initiates a one-off purchase using previously stored
140
+ # credentials. The amount and date were not pre-agreed (unscheduled), but the
141
+ # customer is present and initiates the transaction themselves.
142
+ # ---------------------------------------------------------------------------
143
+ def create_ucof_cit_payment(sdk)
144
+ request = {
145
+ transaction_amount: 250.00,
146
+ token: '<CARD_TOKEN>',
147
+ description: 'One-off purchase with stored credentials',
148
+ installments: 1,
149
+ payment_method_id: '<PAYMENT_METHOD_ID>',
150
+ payer: {
151
+ email: '<PAYER_EMAIL>',
152
+ identification: {
153
+ type: '<ID_TYPE>',
154
+ number: '<ID_NUMBER>'
155
+ }
156
+ },
157
+ # CREDENTIAL_ON_FILE block for an unscheduled customer-initiated purchase.
158
+ point_of_interaction: {
159
+ linked_to: 'subscription',
160
+ transaction_data: {
161
+ # type: "CREDENTIAL_ON_FILE" — credential-on-file payment type.
162
+ type: 'CREDENTIAL_ON_FILE',
163
+ # sub_type: "unscheduled" — amount/date not pre-agreed; ad-hoc purchase.
164
+ sub_type: 'unscheduled',
165
+ # storage: "stored" — credentials already stored from a prior enrollment.
166
+ storage: 'stored',
167
+ # transaction_initiator: "customer" — the customer triggered this charge.
168
+ transaction_initiator: 'customer',
169
+ # first_transaction: false — uses previously stored credentials.
170
+ first_transaction: false
171
+ }
172
+ }
173
+ }
174
+
175
+ custom_headers = {
176
+ 'X-Idempotency-Key': '<SOME_UNIQUE_VALUE>'
177
+ }
178
+ custom_request_options = Mercadopago::RequestOptions.new(custom_headers: custom_headers)
179
+
180
+ result = sdk.payment.create(request, request_options: custom_request_options)
181
+ payment = result[:response]
182
+ puts "UCOF-CIT payment created. ID: #{payment['id']}, status: #{payment['status']}"
183
+ rescue MercadoPago::MPApiException => e
184
+ puts "Status code: #{e.api_response.status_code}"
185
+ puts "Content: #{e.api_response.content}"
186
+ rescue StandardError => e
187
+ puts e.message
188
+ end
189
+
190
+ # Run all three scenarios sequentially.
191
+ # In production, CIT runs once at enrollment; MIT runs on each billing cycle.
192
+ cit_payment_id = create_cit_payment(sdk)
193
+ create_mit_payment(sdk, cit_payment_id) if cit_payment_id
194
+ create_ucof_cit_payment(sdk)
@@ -13,6 +13,9 @@ module Mercadopago
13
13
  # opts = Mercadopago::RequestOptions.new(access_token: token, connection_timeout: 120.0)
14
14
  # sdk.payment.get(123, request_options: opts)
15
15
  class RequestOptions
16
+ DEFAULT_RETRY_ON = [429, 500, 502, 503, 504].freeze
17
+ DEFAULT_MAX_DELAY = 30_000
18
+
16
19
  # @!attribute [r] access_token
17
20
  # @return [String, nil] OAuth access token used for Bearer authentication
18
21
  # @!attribute [r] connection_timeout
@@ -27,8 +30,18 @@ module Mercadopago
27
30
  # @return [String, nil] MercadoPago platform identifier (x-platform-id header)
28
31
  # @!attribute [r] max_retries
29
32
  # @return [Integer] maximum automatic retries on transient HTTP errors (default: 3)
33
+ # @!attribute [r] initial_delay_ms
34
+ # @return [Integer, nil] initial backoff delay in ms (nil = SDK default)
35
+ # @!attribute [r] max_delay_ms
36
+ # @return [Integer] maximum backoff delay cap in ms (default: 30_000)
37
+ # @!attribute [r] jitter
38
+ # @return [Boolean] add random jitter to retry delay using SecureRandom
39
+ # @!attribute [r] retry_on
40
+ # @return [Array<Integer>, nil] HTTP status codes to retry (nil = DEFAULT_RETRY_ON)
41
+ # @!attribute [r] on_retry
42
+ # @return [Proc, nil] callback(attempt, error) invoked before each retry
30
43
  attr_reader :access_token, :connection_timeout, :custom_headers, :corporation_id, :integrator_id,
31
- :platform_id, :max_retries
44
+ :platform_id, :max_retries, :initial_delay_ms, :max_delay_ms, :jitter, :retry_on, :on_retry
32
45
 
33
46
  # Builds a new request configuration.
34
47
  #
@@ -39,6 +52,11 @@ module Mercadopago
39
52
  # @param integrator_id [String, nil] integrator identifier for certified partners
40
53
  # @param platform_id [String, nil] platform identifier for marketplace integrations
41
54
  # @param max_retries [Integer] retry limit for transient failures (429, 5xx)
55
+ # @param initial_delay_ms [Integer, nil] initial backoff delay in ms
56
+ # @param max_delay_ms [Integer] maximum backoff delay cap in ms
57
+ # @param jitter [Boolean] add SecureRandom jitter to retry delay
58
+ # @param retry_on [Array<Integer>, nil] HTTP status codes to retry
59
+ # @param on_retry [Proc, nil] callback invoked before each retry
42
60
  # @raise [TypeError] if any parameter is not the expected type
43
61
  def initialize(access_token: nil,
44
62
  connection_timeout: 60.0,
@@ -46,7 +64,12 @@ module Mercadopago
46
64
  corporation_id: nil,
47
65
  integrator_id: nil,
48
66
  platform_id: nil,
49
- max_retries: 3)
67
+ max_retries: 3,
68
+ initial_delay_ms: nil,
69
+ max_delay_ms: DEFAULT_MAX_DELAY,
70
+ jitter: false,
71
+ retry_on: nil,
72
+ on_retry: nil)
50
73
  self.access_token = access_token
51
74
  self.connection_timeout = connection_timeout
52
75
  self.custom_headers = custom_headers
@@ -54,6 +77,11 @@ module Mercadopago
54
77
  self.integrator_id = integrator_id
55
78
  self.platform_id = platform_id
56
79
  self.max_retries = max_retries
80
+ @initial_delay_ms = initial_delay_ms
81
+ @max_delay_ms = max_delay_ms
82
+ @jitter = jitter
83
+ @retry_on = retry_on
84
+ @on_retry = on_retry
57
85
 
58
86
  @config = Config.new
59
87
  end
@@ -81,8 +81,16 @@ module Mercadopago
81
81
  request_options = _check_request_options(request_options)
82
82
  headers = _check_headers(request_options)
83
83
 
84
- @http_client.get(url: @config.api_base_url + uri, headers: headers, params: filters,
85
- timeout: request_options.connection_timeout, maxretries: request_options.max_retries)
84
+ MPResponse.new(
85
+ @http_client.get(url: @config.api_base_url + uri, headers: headers, params: filters,
86
+ timeout: request_options.connection_timeout,
87
+ maxretries: request_options.max_retries,
88
+ retry_on: request_options.retry_on,
89
+ initial_delay_ms: request_options.initial_delay_ms,
90
+ max_delay_ms: request_options.max_delay_ms,
91
+ jitter: request_options.jitter,
92
+ on_retry: request_options.on_retry)
93
+ )
86
94
  end
87
95
 
88
96
  # Performs a POST request against the MercadoPago API.
@@ -101,7 +109,10 @@ module Mercadopago
101
109
  headers = _check_headers(request_options, { 'Content-Type': @config.mime_json })
102
110
  payload = data&.to_json
103
111
 
104
- @http_client.post(url: @config.api_base_url + uri, data: payload, headers: headers, timeout: request_options.connection_timeout)
112
+ MPResponse.new(
113
+ @http_client.post(url: @config.api_base_url + uri, data: payload, headers: headers,
114
+ timeout: request_options.connection_timeout)
115
+ )
105
116
  end
106
117
 
107
118
  # Performs a PUT request against the MercadoPago API.
@@ -119,8 +130,10 @@ module Mercadopago
119
130
  request_options = _check_request_options(request_options)
120
131
  headers = _check_headers(request_options, { 'Content-Type': @config.mime_json })
121
132
 
122
- @http_client.put(url: @config.api_base_url + uri, data: data.to_json, headers: headers,
123
- timeout: request_options.connection_timeout)
133
+ MPResponse.new(
134
+ @http_client.put(url: @config.api_base_url + uri, data: data.to_json, headers: headers,
135
+ timeout: request_options.connection_timeout)
136
+ )
124
137
  end
125
138
 
126
139
  # Performs a DELETE request against the MercadoPago API.
@@ -132,8 +145,10 @@ module Mercadopago
132
145
  request_options = _check_request_options(request_options)
133
146
  headers = _check_headers(request_options)
134
147
 
135
- @http_client.delete(url: @config.api_base_url + uri, headers: headers,
136
- timeout: request_options.connection_timeout)
148
+ MPResponse.new(
149
+ @http_client.delete(url: @config.api_base_url + uri, headers: headers,
150
+ timeout: request_options.connection_timeout)
151
+ )
137
152
  end
138
153
  end
139
154
  end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mercadopago
4
+ # Base exception for all MercadoPago API errors.
5
+ #
6
+ # All subtypes inherit from this class, preserving backward-compatible
7
+ # +rescue MercadoPagoError+ patterns while enabling specific handling
8
+ # per HTTP status code.
9
+ #
10
+ # @attr_reader [Integer] status_code HTTP status code (e.g. 400, 401, 500)
11
+ # @attr_reader [String] error Machine-readable error code from the API body
12
+ # @attr_reader [Array] causes List of detailed cause hashes from the API body
13
+ # @attr_reader [String, nil] request_id x-request-id header for support diagnostics
14
+ class MercadoPagoError < StandardError
15
+ attr_reader :status_code, :error, :causes, :request_id
16
+
17
+ # @param status_code [Integer] HTTP status code
18
+ # @param response_body [Hash] parsed API error body
19
+ # @param request_id [String, nil] value of the x-request-id response header
20
+ def initialize(status_code, response_body = nil, request_id = nil)
21
+ body = (response_body.is_a?(Hash) ? response_body : {})
22
+ super(body['message'] || body[:message] || body['error'] || body[:error] || 'MercadoPago API error')
23
+ @status_code = status_code
24
+ @error = body['error'] || body[:error] || ''
25
+ @causes = body['cause'] || body[:cause] || []
26
+ @request_id = request_id
27
+ end
28
+ end
29
+
30
+ # HTTP 400 Bad Request — validation or syntax error.
31
+ class MPBadRequestError < MercadoPagoError; end
32
+
33
+ # HTTP 401 Unauthorized — missing or invalid credentials.
34
+ class MPAuthenticationError < MercadoPagoError; end
35
+
36
+ # HTTP 402 Payment Required — transaction processing error (AP/Orders).
37
+ class MPPaymentError < MercadoPagoError; end
38
+
39
+ # HTTP 403 Forbidden.
40
+ class MPForbiddenError < MercadoPagoError; end
41
+
42
+ # HTTP 404 Not Found.
43
+ class MPNotFoundError < MercadoPagoError; end
44
+
45
+ # HTTP 409 Conflict — idempotency-key conflict or state-machine conflict.
46
+ class MPIdempotencyError < MercadoPagoError; end
47
+
48
+ # HTTP 422 Unprocessable Entity — business-rule violation.
49
+ class MPValidationError < MercadoPagoError; end
50
+
51
+ # HTTP 423 Locked — idempotency key temporarily locked (retryable).
52
+ class MPResourceLockedError < MercadoPagoError; end
53
+
54
+ # HTTP 424 Failed Dependency — internal dependency failure (retryable).
55
+ class MPDependencyError < MercadoPagoError; end
56
+
57
+ # HTTP 429 Too Many Requests.
58
+ # Exposes +retry_after+ (seconds) from the +Retry-After+ response header.
59
+ #
60
+ # @attr_reader [Integer, nil] retry_after seconds to wait before retrying
61
+ class MPRateLimitError < MercadoPagoError
62
+ attr_reader :retry_after
63
+
64
+ def initialize(status_code, response_body = nil, retry_after = nil, request_id = nil)
65
+ super(status_code, response_body, request_id)
66
+ @retry_after = retry_after
67
+ end
68
+ end
69
+
70
+ # HTTP 5xx Server Error.
71
+ class MPServerError < MercadoPagoError; end
72
+
73
+ # Transport-level or network error (timeout, DNS failure, SSL error).
74
+ class MPConnectionError < MercadoPagoError
75
+ def initialize(cause)
76
+ super(0, { 'error' => 'connection_error', 'message' => cause.to_s })
77
+ end
78
+ end
79
+
80
+ # @!visibility private
81
+ STATUS_MAP = {
82
+ 400 => MPBadRequestError,
83
+ 401 => MPAuthenticationError,
84
+ 402 => MPPaymentError,
85
+ 403 => MPForbiddenError,
86
+ 404 => MPNotFoundError,
87
+ 409 => MPIdempotencyError,
88
+ 422 => MPValidationError,
89
+ 423 => MPResourceLockedError,
90
+ 424 => MPDependencyError
91
+ }.freeze
92
+
93
+ # Factory: maps an HTTP status code to the most specific exception subtype.
94
+ #
95
+ # @param status_code [Integer] HTTP status code from the API response
96
+ # @param response_body [Hash] parsed API error body
97
+ # @param retry_after [Integer, nil] seconds from Retry-After header (429 only)
98
+ # @return [MercadoPagoError] the appropriate subtype instance
99
+ def self.build_error(status_code, response_body = nil, retry_after = nil)
100
+ return MPRateLimitError.new(status_code, response_body, retry_after) if status_code == 429
101
+
102
+ klass = STATUS_MAP[status_code]
103
+ return klass.new(status_code, response_body) if klass
104
+ return MPServerError.new(status_code, response_body) if status_code >= 500
105
+
106
+ MercadoPagoError.new(status_code, response_body)
107
+ end
108
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mercadopago
4
+ # Backward-compatible Hash wrapper for MercadoPago API responses.
5
+ #
6
+ # Wraps the raw +{ status:, response: }+ hash returned by every
7
+ # {MPBase} helper. Existing code that reads +result[:status]+ or
8
+ # +result[:response]+ continues to work unchanged.
9
+ #
10
+ # New code may call {#raise_for_status!} to get a typed exception or
11
+ # use the convenience accessors {#success?}, {#error_message}, {#error_causes}.
12
+ class MPResponse < Hash
13
+ # @param raw [Hash] the raw response hash with +:status+ and +:response+ keys
14
+ # @param request_id [String, nil] value of the +x-request-id+ response header
15
+ def initialize(raw = {}, request_id: nil)
16
+ super()
17
+ update(raw)
18
+ @request_id = request_id
19
+ end
20
+
21
+ # @return [Integer] HTTP status code
22
+ def status_code
23
+ self[:status] || self['status'] || 0
24
+ end
25
+
26
+ # @return [Boolean] true for 2xx status codes
27
+ def success?
28
+ status_code >= 200 && status_code < 300
29
+ end
30
+
31
+ # @return [String] human-readable error message from the API body
32
+ def error_message
33
+ body = self[:response] || self['response'] || {}
34
+ body.is_a?(Hash) ? (body['message'] || body[:message] || '') : ''
35
+ end
36
+
37
+ # @return [Array] cause list from the API body
38
+ def error_causes
39
+ body = self[:response] || self['response'] || {}
40
+ body.is_a?(Hash) ? (body['cause'] || body[:cause] || []) : []
41
+ end
42
+
43
+ # @return [String, nil] x-request-id header value
44
+ def request_id
45
+ @request_id
46
+ end
47
+
48
+ # Raises a typed {MercadoPagoError} subclass when the response status is not 2xx.
49
+ #
50
+ # @raise [MercadoPagoError] (or subtype) when status >= 300
51
+ def raise_for_status!
52
+ return if success?
53
+
54
+ body = self[:response] || self['response'] || {}
55
+ raise Mercadopago.build_error(status_code, body)
56
+ end
57
+ end
58
+ end