checkout-intents 0.0.2 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bc70fb7635edef3da40cc306e01a4e0754caee4ae66652784a95b1da116c073c
4
- data.tar.gz: 560d24479d9c9d730a35f55ec5224f595983884a3d5accf419975a753d522dd8
3
+ metadata.gz: 8cf5e6cd633f4f7ee6338c4bea928549742449f2866fa6bbd2ae92b6421ef31c
4
+ data.tar.gz: 852728890666c85d7d1a0e8adeda2d59a5b2ce29c3cee5e3799044f740f9e543
5
5
  SHA512:
6
- metadata.gz: 1c5fcfbf0a8d880d86396ef3f6c1e2040eb039b15e89be624cbd253203c87b0eb4c599e421cf2bf6b98bbae53f4ab92aa29a016327994b4dac2acaac9c6cdc02
7
- data.tar.gz: b5a0f0238c5306f03bda47b7ecfad94e2137c69f53a4109c16f1615498ecab63c5a82a7118f562cceeb13429aade81855a6948b531664127b2c832db9254c296
6
+ metadata.gz: 0034c8b0230278a365e8d1193ba49d1cd32e9fc03a69794436e2ef407dcd73b6b7637e40587951695621099bc0e71a619725f896e91d0b1e335bef346cbae651
7
+ data.tar.gz: 2f2dfb10980745a1f0f3b127a4e31faac529af16e3091c753d7f53e9341e29ac558c3308ca68cfcd33a1033cab7299fc47f05e78e1ba7a85938fc472384596df
data/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.0 (2026-01-12)
4
+
5
+ Full Changelog: [v0.0.2...v0.1.0](https://github.com/rye-com/checkout-intents-ruby/compare/v0.0.2...v0.1.0)
6
+
7
+ ### Features
8
+
9
+ * **api:** add polling helpers ([5209a5f](https://github.com/rye-com/checkout-intents-ruby/commit/5209a5fcb449acf6e4ab52e1711b5c95a5acfa63))
10
+ * **api:** auto infer environment value ([d666fbb](https://github.com/rye-com/checkout-intents-ruby/commit/d666fbb211955c2c02c9fb5c88de0dea6240adf1))
11
+
12
+
13
+ ### Chores
14
+
15
+ * **internal:** add request_with_headers method ([89dc817](https://github.com/rye-com/checkout-intents-ruby/commit/89dc817a36404e5231e65ab7f05ebe1e5766c61f))
16
+ * move `cgi` into dependencies for ruby 4 ([7674f56](https://github.com/rye-com/checkout-intents-ruby/commit/7674f56727d3e5d940c110eb173d91148224c21b))
17
+
18
+
19
+ ### Documentation
20
+
21
+ * **api:** polling helpers ([465f0a0](https://github.com/rye-com/checkout-intents-ruby/commit/465f0a06848b014fd4e3526ff5c2a01b68f3b7ec))
22
+
3
23
  ## 0.0.2 (2026-01-07)
4
24
 
5
25
  Full Changelog: [v0.0.1...v0.0.2](https://github.com/rye-com/checkout-intents-ruby/compare/v0.0.1...v0.0.2)
@@ -13,4 +33,5 @@ Full Changelog: [v0.0.1...v0.0.2](https://github.com/rye-com/checkout-intents-ru
13
33
  ### Chores
14
34
 
15
35
  * configure new SDK language ([ee73dec](https://github.com/rye-com/checkout-intents-ruby/commit/ee73dec601f3c6286045a97a8c900ade97e9099f))
36
+ * sync repo ([c2a3795](https://github.com/rye-com/checkout-intents-ruby/commit/c2a379536364e5dbdfc5aabfaf20383817a1e2b1))
16
37
  * update SDK settings ([a11c510](https://github.com/rye-com/checkout-intents-ruby/commit/a11c51015c6bcdbbc51353813a10b4421f9ebf44))
data/README.md CHANGED
@@ -17,7 +17,7 @@ To use this gem, install via Bundler by adding the following to your application
17
17
  <!-- x-release-please-start-version -->
18
18
 
19
19
  ```ruby
20
- gem "checkout-intents", "~> 0.0.2"
20
+ gem "checkout-intents", "~> 0.1.0"
21
21
  ```
22
22
 
23
23
  <!-- x-release-please-end -->
@@ -81,6 +81,63 @@ if page.next_page?
81
81
  end
82
82
  ```
83
83
 
84
+ ### Polling Helpers
85
+
86
+ This SDK includes helper methods for the asynchronous checkout flow. The recommended pattern follows Rye's two-phase checkout:
87
+
88
+ ```ruby
89
+ # Phase 1: Create and wait for offer
90
+ intent = checkout_intents.checkout_intents.create_and_poll(
91
+ buyer: {
92
+ address1: "123 Main St",
93
+ city: "New York",
94
+ country: "US",
95
+ email: "john.doe@example.com",
96
+ firstName: "John",
97
+ lastName: "Doe",
98
+ phone: "5555555555",
99
+ postalCode: "10001",
100
+ province: "NY"
101
+ },
102
+ product_url: "https://example.com/product",
103
+ quantity: 1
104
+ )
105
+
106
+ # Handle failure during offer retrieval
107
+ if intent.state == CheckoutIntents::CheckoutIntent::FailedCheckoutIntent::State::FAILED
108
+ puts "Failed: #{intent.failure_reason.message}"
109
+ else
110
+ # Review pricing with user
111
+ puts "Total: #{intent.offer.cost.total}"
112
+
113
+ # Phase 2: Confirm and wait for completion
114
+ completed = checkout_intents.checkout_intents.confirm_and_poll(
115
+ intent.id,
116
+ payment_method: { type: :stripe_token, stripeToken: "tok_visa" }
117
+ )
118
+
119
+ puts "Status: #{completed.state}"
120
+ end
121
+ ```
122
+
123
+ Available polling methods:
124
+
125
+ - `create_and_poll` - Create and poll until offer is ready (awaiting_confirmation or failed)
126
+ - `confirm_and_poll` - Confirm and poll until completion (completed or failed)
127
+ - `poll_until_completed` - Poll until completed or failed
128
+ - `poll_until_awaiting_confirmation` - Poll until offer is ready or failed
129
+
130
+ All polling methods support customizable timeouts:
131
+
132
+ ```ruby
133
+ # Configure polling behavior
134
+ intent = checkout_intents.checkout_intents.poll_until_completed(
135
+ intent_id,
136
+ poll_interval: 5.0, # Poll every 5 seconds (default)
137
+ max_attempts: 120 # Try up to 120 times, ~10 minutes (default)
138
+ )
139
+ ```
140
+
84
141
  ### Handling errors
85
142
 
86
143
  When the library is unable to connect to the API, or if the API returns a non-success status code (i.e., 4xx or 5xx response), a subclass of `CheckoutIntents::Errors::APIError` will be thrown:
@@ -128,6 +185,28 @@ Error codes are as follows:
128
185
  | Other HTTP error | `APIStatusError` |
129
186
  | Timeout | `APITimeoutError` |
130
187
  | Network error | `APIConnectionError` |
188
+ | Polling timeout | `PollTimeoutError` |
189
+
190
+ ### Polling Timeout Errors
191
+
192
+ When using polling helper methods, if the operation exceeds the configured `max_attempts`, a `PollTimeoutError` is raised with helpful context:
193
+
194
+ ```ruby
195
+ begin
196
+ intent = checkout_intents.checkout_intents.poll_until_completed(
197
+ "intent_id",
198
+ poll_interval: 5.0,
199
+ max_attempts: 60
200
+ )
201
+ rescue CheckoutIntents::Errors::PollTimeoutError => e
202
+ puts "Polling timed out for intent: #{e.intent_id}"
203
+ puts "Attempted #{e.attempts} times over #{e.attempts * e.poll_interval}s"
204
+
205
+ # You can retrieve the current state manually
206
+ current_intent = checkout_intents.checkout_intents.retrieve(e.intent_id)
207
+ puts "Current state: #{current_intent.state}"
208
+ end
209
+ ```
131
210
 
132
211
  ### Retries
133
212
 
@@ -42,6 +42,19 @@ module CheckoutIntents
42
42
  {"authorization" => "Bearer #{@api_key}"}
43
43
  end
44
44
 
45
+ # Extracts the environment from a Rye API key.
46
+ # API keys follow the format: RYE/{environment}-{key}
47
+ #
48
+ # @api private
49
+ #
50
+ # @param api_key [String] The API key to parse
51
+ #
52
+ # @return [Symbol, nil] The extracted environment (:staging or :production), or nil if format doesn't match
53
+ private_class_method def self.extract_environment_from_api_key(api_key)
54
+ match = api_key.match(%r{\ARYE/(staging|production)-})
55
+ match ? match[1].to_sym : nil
56
+ end
57
+
45
58
  # Creates and returns a new client for interacting with the API.
46
59
  #
47
60
  # @param api_key [String, nil] Rye API key. Format: `RYE/{environment}-abcdef` Defaults to
@@ -73,17 +86,33 @@ module CheckoutIntents
73
86
  initial_retry_delay: self.class::DEFAULT_INITIAL_RETRY_DELAY,
74
87
  max_retry_delay: self.class::DEFAULT_MAX_RETRY_DELAY
75
88
  )
76
- base_url ||= CheckoutIntents::Client::ENVIRONMENTS.fetch(environment&.to_sym || :production) do
77
- message = "environment must be one of #{CheckoutIntents::Client::ENVIRONMENTS.keys}, got #{environment}"
78
- raise ArgumentError.new(message)
79
- end
80
-
81
89
  if api_key.nil?
82
90
  raise ArgumentError.new("api_key is required, and can be set via environ: \"CHECKOUT_INTENTS_API_KEY\"")
83
91
  end
84
92
 
85
93
  @api_key = api_key.to_s
86
94
 
95
+ # Auto-infer environment from API key
96
+ inferred_environment = self.class.send(:extract_environment_from_api_key, @api_key)
97
+
98
+ # Validate environment option matches API key (if both provided)
99
+ if environment && inferred_environment && environment.to_sym != inferred_environment
100
+ raise ArgumentError.new(
101
+ "Environment mismatch: API key is for '#{inferred_environment}' environment " \
102
+ "but 'environment' option is set to '#{environment}'. Please use an API key that " \
103
+ "matches your desired environment or omit the 'environment' option to auto-detect " \
104
+ "from the API key (only auto-detectable with the RYE/{environment}-abcdef api key format)."
105
+ )
106
+ end
107
+
108
+ # Resolve environment: explicit > inferred > default (staging)
109
+ resolved_environment = environment&.to_sym || inferred_environment || :staging
110
+
111
+ base_url ||= CheckoutIntents::Client::ENVIRONMENTS.fetch(resolved_environment) do
112
+ message = "environment must be one of #{CheckoutIntents::Client::ENVIRONMENTS.keys}, got #{resolved_environment}"
113
+ raise ArgumentError.new(message)
114
+ end
115
+
87
116
  super(
88
117
  base_url: base_url,
89
118
  timeout: timeout,
@@ -224,5 +224,38 @@ module CheckoutIntents
224
224
  class InternalServerError < CheckoutIntents::Errors::APIStatusError
225
225
  HTTP_STATUS = (500..)
226
226
  end
227
+
228
+ class PollTimeoutError < CheckoutIntents::Errors::Error
229
+ # @return [String]
230
+ attr_reader :intent_id
231
+
232
+ # @return [Integer]
233
+ attr_reader :attempts
234
+
235
+ # @return [Float]
236
+ attr_reader :poll_interval
237
+
238
+ # @return [Integer]
239
+ attr_reader :max_attempts
240
+
241
+ # @api private
242
+ #
243
+ # @param intent_id [String]
244
+ # @param attempts [Integer]
245
+ # @param poll_interval [Float]
246
+ # @param max_attempts [Integer]
247
+ # @param message [String, nil]
248
+ def initialize(intent_id:, attempts:, poll_interval:, max_attempts:, message: nil)
249
+ @intent_id = intent_id
250
+ @attempts = attempts
251
+ @poll_interval = poll_interval
252
+ @max_attempts = max_attempts
253
+
254
+ message ||= "Polling timeout for checkout intent '#{intent_id}': " \
255
+ "condition not met after #{attempts} attempts (#{(max_attempts * poll_interval).round(1)}s). " \
256
+ "Consider increasing max_attempts or poll_interval."
257
+ super(message)
258
+ end
259
+ end
227
260
  end
228
261
  end
@@ -518,6 +518,39 @@ module CheckoutIntents
518
518
  end
519
519
  end
520
520
 
521
+ # @api private
522
+ #
523
+ # Like `request`, but returns both the parsed model and response headers.
524
+ # Used internally for polling helpers that need to inspect headers.
525
+ #
526
+ # @param req [Hash{Symbol=>Object}]
527
+ #
528
+ # @raise [CheckoutIntents::Errors::APIError]
529
+ # @return [Hash{Symbol=>Object}] Hash with :data and :headers keys
530
+ def request_with_headers(req)
531
+ self.class.validate!(req)
532
+ model = req.fetch(:model) { CheckoutIntents::Internal::Type::Unknown }
533
+ opts = req[:options].to_h
534
+ unwrap = req[:unwrap]
535
+ CheckoutIntents::RequestOptions.validate!(opts)
536
+ request = build_request(req.except(:options), opts)
537
+
538
+ send_retry_header = request.fetch(:headers)["x-stainless-retry-count"] == "0"
539
+ _status, response, stream = send_request(
540
+ request,
541
+ redirect_count: 0,
542
+ retry_count: 0,
543
+ send_retry_header: send_retry_header
544
+ )
545
+
546
+ headers = CheckoutIntents::Internal::Util.normalized_headers(response.each_header.to_h)
547
+ decoded = CheckoutIntents::Internal::Util.decode_content(headers, stream: stream)
548
+ unwrapped = CheckoutIntents::Internal::Util.dig(decoded, unwrap)
549
+ data = CheckoutIntents::Internal::Type::Converter.coerce(model, unwrapped)
550
+
551
+ {data: data, headers: headers}
552
+ end
553
+
521
554
  # @api private
522
555
  #
523
556
  # @return [String]
@@ -3,6 +3,11 @@
3
3
  module CheckoutIntents
4
4
  module Resources
5
5
  class CheckoutIntents
6
+ # Default polling interval in seconds
7
+ DEFAULT_POLL_INTERVAL = 5.0
8
+
9
+ # Default maximum polling attempts
10
+ DEFAULT_MAX_ATTEMPTS = 120
6
11
  # Create a checkout intent with the given request body.
7
12
  #
8
13
  # @overload create(buyer:, product_url:, quantity:, constraints: nil, promo_codes: nil, variant_selections: nil, request_options: {})
@@ -162,12 +167,229 @@ module CheckoutIntents
162
167
  )
163
168
  end
164
169
 
170
+ # Poll a checkout intent until it reaches a completed state (completed or failed).
171
+ #
172
+ # @param id [String] The checkout intent ID to poll
173
+ # @param poll_interval [Float] Seconds between polling attempts (default: 5.0)
174
+ # @param max_attempts [Integer] Maximum polling attempts before timeout (default: 120)
175
+ # @param request_options [::CheckoutIntents::RequestOptions, Hash{Symbol=>Object}, nil]
176
+ #
177
+ # @return [::CheckoutIntents::Models::CheckoutIntent::CompletedCheckoutIntent, ::CheckoutIntents::Models::CheckoutIntent::FailedCheckoutIntent]
178
+ #
179
+ # @raise [::CheckoutIntents::Errors::PollTimeoutError] If max attempts reached without reaching terminal state
180
+ #
181
+ # @example
182
+ # intent = client.checkout_intents.poll_until_completed("ci_123")
183
+ # if intent.state == :completed
184
+ # puts "Order placed successfully!"
185
+ # else
186
+ # puts "Order failed: #{intent.failure_reason.message}"
187
+ # end
188
+ def poll_until_completed(
189
+ id,
190
+ poll_interval: DEFAULT_POLL_INTERVAL,
191
+ max_attempts: DEFAULT_MAX_ATTEMPTS,
192
+ request_options: {}
193
+ )
194
+ poll_until(
195
+ id,
196
+ ->(intent) { [:completed, :failed].include?(intent.state) },
197
+ poll_interval: poll_interval,
198
+ max_attempts: max_attempts,
199
+ request_options: request_options
200
+ )
201
+ end
202
+
203
+ # Poll a checkout intent until it's ready for confirmation (awaiting_confirmation or failed).
204
+ #
205
+ # This is typically used after creating a checkout intent to wait for the offer
206
+ # to be retrieved from the merchant. The intent can reach awaiting_confirmation
207
+ # (success - ready to confirm) or failed (offer retrieval failed).
208
+ #
209
+ # @param id [String] The checkout intent ID to poll
210
+ # @param poll_interval [Float] Seconds between polling attempts (default: 5.0)
211
+ # @param max_attempts [Integer] Maximum polling attempts before timeout (default: 120)
212
+ # @param request_options [::CheckoutIntents::RequestOptions, Hash{Symbol=>Object}, nil]
213
+ #
214
+ # @return [::CheckoutIntents::Models::CheckoutIntent::AwaitingConfirmationCheckoutIntent, ::CheckoutIntents::Models::CheckoutIntent::FailedCheckoutIntent]
215
+ #
216
+ # @raise [::CheckoutIntents::Errors::PollTimeoutError] If max attempts reached without reaching target state
217
+ #
218
+ # @example
219
+ # intent = client.checkout_intents.poll_until_awaiting_confirmation("ci_123")
220
+ # if intent.state == :awaiting_confirmation
221
+ # puts "Total: #{intent.offer.cost.total}"
222
+ # else
223
+ # puts "Failed: #{intent.failure_reason.message}"
224
+ # end
225
+ def poll_until_awaiting_confirmation(
226
+ id,
227
+ poll_interval: DEFAULT_POLL_INTERVAL,
228
+ max_attempts: DEFAULT_MAX_ATTEMPTS,
229
+ request_options: {}
230
+ )
231
+ poll_until(
232
+ id,
233
+ ->(intent) { [:awaiting_confirmation, :failed].include?(intent.state) },
234
+ poll_interval: poll_interval,
235
+ max_attempts: max_attempts,
236
+ request_options: request_options
237
+ )
238
+ end
239
+
240
+ # Create a checkout intent and poll until it's ready for confirmation.
241
+ #
242
+ # This follows the Rye documented flow: create -> poll until awaiting_confirmation.
243
+ # After this method completes, you should review the offer (pricing, shipping, taxes)
244
+ # with the user before calling confirm().
245
+ #
246
+ # @overload create_and_poll(buyer:, product_url:, quantity:, constraints: nil, promo_codes: nil, variant_selections: nil, poll_interval: 5.0, max_attempts: 120, request_options: {})
247
+ #
248
+ # @param buyer [::CheckoutIntents::Models::Buyer]
249
+ # @param product_url [String]
250
+ # @param quantity [Float]
251
+ # @param constraints [::CheckoutIntents::Models::CheckoutIntentCreateParams::Constraints]
252
+ # @param promo_codes [Array<String>]
253
+ # @param variant_selections [Array<::CheckoutIntents::Models::VariantSelection>]
254
+ # @param poll_interval [Float] Seconds between polling attempts (default: 5.0)
255
+ # @param max_attempts [Integer] Maximum polling attempts before timeout (default: 120)
256
+ # @param request_options [::CheckoutIntents::RequestOptions, Hash{Symbol=>Object}, nil]
257
+ #
258
+ # @return [::CheckoutIntents::Models::CheckoutIntent::AwaitingConfirmationCheckoutIntent, ::CheckoutIntents::Models::CheckoutIntent::FailedCheckoutIntent]
259
+ #
260
+ # @raise [::CheckoutIntents::Errors::PollTimeoutError] If max attempts reached without reaching target state
261
+ #
262
+ # @example
263
+ # intent = client.checkout_intents.create_and_poll(
264
+ # buyer: { address1: "123 Main St", city: "New York", ... },
265
+ # product_url: "https://example.com/product",
266
+ # quantity: 1
267
+ # )
268
+ # puts "Total: #{intent.offer.cost.total}"
269
+ def create_and_poll(
270
+ params,
271
+ poll_interval: DEFAULT_POLL_INTERVAL,
272
+ max_attempts: DEFAULT_MAX_ATTEMPTS
273
+ )
274
+ intent = create(params)
275
+ poll_until_awaiting_confirmation(
276
+ intent.id,
277
+ poll_interval: poll_interval,
278
+ max_attempts: max_attempts,
279
+ request_options: params[:request_options] || {}
280
+ )
281
+ end
282
+
283
+ # Confirm a checkout intent and poll until it reaches a completed state (completed or failed).
284
+ #
285
+ # @overload confirm_and_poll(id, payment_method:, poll_interval: 5.0, max_attempts: 120, request_options: {})
286
+ #
287
+ # @param id [String] The id of the checkout intent to confirm
288
+ # @param payment_method [::CheckoutIntents::Models::PaymentMethod::StripeTokenPaymentMethod, ::CheckoutIntents::Models::PaymentMethod::BasisTheoryPaymentMethod, ::CheckoutIntents::Models::PaymentMethod::NekudaPaymentMethod]
289
+ # @param poll_interval [Float] Seconds between polling attempts (default: 5.0)
290
+ # @param max_attempts [Integer] Maximum polling attempts before timeout (default: 120)
291
+ # @param request_options [::CheckoutIntents::RequestOptions, Hash{Symbol=>Object}, nil]
292
+ #
293
+ # @return [::CheckoutIntents::Models::CheckoutIntent::CompletedCheckoutIntent, ::CheckoutIntents::Models::CheckoutIntent::FailedCheckoutIntent]
294
+ #
295
+ # @raise [::CheckoutIntents::Errors::PollTimeoutError] If max attempts reached without reaching terminal state
296
+ #
297
+ # @example
298
+ # intent = client.checkout_intents.confirm_and_poll(
299
+ # "ci_123",
300
+ # payment_method: { type: "stripe_token", stripe_token: "tok_visa" }
301
+ # )
302
+ # if intent.state == :completed
303
+ # puts "Order placed successfully!"
304
+ # else
305
+ # puts "Order failed: #{intent.failure_reason.message}"
306
+ # end
307
+ def confirm_and_poll(
308
+ id,
309
+ params,
310
+ poll_interval: DEFAULT_POLL_INTERVAL,
311
+ max_attempts: DEFAULT_MAX_ATTEMPTS
312
+ )
313
+ intent = confirm(id, params)
314
+ poll_until_completed(
315
+ intent.id,
316
+ poll_interval: poll_interval,
317
+ max_attempts: max_attempts,
318
+ request_options: params[:request_options] || {}
319
+ )
320
+ end
321
+
165
322
  # @api private
166
323
  #
167
324
  # @param client [::CheckoutIntents::Client]
168
325
  def initialize(client:)
169
326
  @client = client
170
327
  end
328
+
329
+ private
330
+
331
+ # Core polling implementation.
332
+ #
333
+ # @param id [String] The checkout intent ID
334
+ # @param condition [Proc] Returns true when polling should stop
335
+ # @param poll_interval [Float] Seconds between polls
336
+ # @param max_attempts [Integer] Maximum attempts
337
+ # @param request_options [Hash] Additional request options
338
+ #
339
+ # @return [::CheckoutIntents::Models::CheckoutIntent]
340
+ def poll_until(id, condition, poll_interval:, max_attempts:, request_options:)
341
+ if max_attempts < 1
342
+ warn(
343
+ "[Checkout Intents SDK] Invalid max_attempts value: #{max_attempts}. " \
344
+ "max_attempts must be >= 1. Defaulting to 1."
345
+ )
346
+ max_attempts = 1
347
+ end
348
+
349
+ attempts = 0
350
+ poll_headers = {
351
+ "x-stainless-poll-helper" => "true",
352
+ "x-stainless-custom-poll-interval" => (poll_interval * 1000).to_i.to_s
353
+ }
354
+
355
+ merged_options = (request_options || {}).merge(
356
+ extra_headers: ((request_options || {})[:extra_headers] || {}).merge(poll_headers)
357
+ )
358
+
359
+ while attempts < max_attempts
360
+ result = @client.request_with_headers(
361
+ method: :get,
362
+ path: ["api/v1/checkout-intents/%1$s", id],
363
+ model: ::CheckoutIntents::CheckoutIntent,
364
+ options: merged_options
365
+ )
366
+
367
+ intent = result[:data]
368
+ headers = result[:headers]
369
+
370
+ return intent if condition.call(intent)
371
+
372
+ attempts += 1
373
+
374
+ if attempts >= max_attempts
375
+ raise ::CheckoutIntents::Errors::PollTimeoutError.new(
376
+ intent_id: id,
377
+ attempts: attempts,
378
+ poll_interval: poll_interval,
379
+ max_attempts: max_attempts
380
+ )
381
+ end
382
+
383
+ # Check for server-suggested polling interval
384
+ sleep_interval = poll_interval
385
+ if (header_interval = headers["retry-after-ms"])
386
+ header_interval_ms = Integer(header_interval, exception: false)
387
+ sleep_interval = header_interval_ms / 1000.0 if header_interval_ms
388
+ end
389
+
390
+ sleep(sleep_interval)
391
+ end
392
+ end
171
393
  end
172
394
  end
173
395
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CheckoutIntents
4
- VERSION = "0.0.2"
4
+ VERSION = "0.1.0"
5
5
  end
@@ -201,5 +201,38 @@ module CheckoutIntents
201
201
  class InternalServerError < CheckoutIntents::Errors::APIStatusError
202
202
  HTTP_STATUS = T.let((500..), T::Range[Integer])
203
203
  end
204
+
205
+ class PollTimeoutError < CheckoutIntents::Errors::Error
206
+ sig { returns(String) }
207
+ attr_reader :intent_id
208
+
209
+ sig { returns(Integer) }
210
+ attr_reader :attempts
211
+
212
+ sig { returns(Float) }
213
+ attr_reader :poll_interval
214
+
215
+ sig { returns(Integer) }
216
+ attr_reader :max_attempts
217
+
218
+ # @api private
219
+ sig do
220
+ params(
221
+ intent_id: String,
222
+ attempts: Integer,
223
+ poll_interval: Float,
224
+ max_attempts: Integer,
225
+ message: T.nilable(String)
226
+ ).returns(T.attached_class)
227
+ end
228
+ def self.new(
229
+ intent_id:,
230
+ attempts:,
231
+ poll_interval:,
232
+ max_attempts:,
233
+ message: nil
234
+ )
235
+ end
236
+ end
204
237
  end
205
238
  end
@@ -299,6 +299,18 @@ module CheckoutIntents
299
299
  )
300
300
  end
301
301
 
302
+ # @api private
303
+ #
304
+ # Like `request`, but returns both the parsed model and response headers.
305
+ # Used internally for polling helpers that need to inspect headers.
306
+ sig do
307
+ params(
308
+ req: CheckoutIntents::Internal::Transport::BaseClient::RequestComponents
309
+ ).returns({ data: T.anything, headers: T::Hash[String, String] })
310
+ end
311
+ def request_with_headers(req)
312
+ end
313
+
302
314
  # @api private
303
315
  sig { returns(String) }
304
316
  def inspect
@@ -156,6 +156,98 @@ module CheckoutIntents
156
156
  )
157
157
  end
158
158
 
159
+ # Default polling interval in seconds
160
+ DEFAULT_POLL_INTERVAL = T.let(5.0, Float)
161
+
162
+ # Default maximum polling attempts
163
+ DEFAULT_MAX_ATTEMPTS = T.let(120, Integer)
164
+
165
+ # Poll a checkout intent until it reaches a completed state (completed or failed).
166
+ sig do
167
+ params(
168
+ id: String,
169
+ poll_interval: Float,
170
+ max_attempts: Integer,
171
+ request_options: ::CheckoutIntents::RequestOptions::OrHash
172
+ ).returns(
173
+ T.any(
174
+ ::CheckoutIntents::CheckoutIntent::CompletedCheckoutIntent,
175
+ ::CheckoutIntents::CheckoutIntent::FailedCheckoutIntent
176
+ )
177
+ )
178
+ end
179
+ def poll_until_completed(
180
+ id,
181
+ poll_interval: DEFAULT_POLL_INTERVAL,
182
+ max_attempts: DEFAULT_MAX_ATTEMPTS,
183
+ request_options: {}
184
+ )
185
+ end
186
+
187
+ # Poll a checkout intent until it's ready for confirmation (awaiting_confirmation or failed).
188
+ sig do
189
+ params(
190
+ id: String,
191
+ poll_interval: Float,
192
+ max_attempts: Integer,
193
+ request_options: ::CheckoutIntents::RequestOptions::OrHash
194
+ ).returns(
195
+ T.any(
196
+ ::CheckoutIntents::CheckoutIntent::AwaitingConfirmationCheckoutIntent,
197
+ ::CheckoutIntents::CheckoutIntent::FailedCheckoutIntent
198
+ )
199
+ )
200
+ end
201
+ def poll_until_awaiting_confirmation(
202
+ id,
203
+ poll_interval: DEFAULT_POLL_INTERVAL,
204
+ max_attempts: DEFAULT_MAX_ATTEMPTS,
205
+ request_options: {}
206
+ )
207
+ end
208
+
209
+ # Create a checkout intent and poll until it's ready for confirmation.
210
+ sig do
211
+ params(
212
+ params: T::Hash[Symbol, T.anything],
213
+ poll_interval: Float,
214
+ max_attempts: Integer
215
+ ).returns(
216
+ T.any(
217
+ ::CheckoutIntents::CheckoutIntent::AwaitingConfirmationCheckoutIntent,
218
+ ::CheckoutIntents::CheckoutIntent::FailedCheckoutIntent
219
+ )
220
+ )
221
+ end
222
+ def create_and_poll(
223
+ params,
224
+ poll_interval: DEFAULT_POLL_INTERVAL,
225
+ max_attempts: DEFAULT_MAX_ATTEMPTS
226
+ )
227
+ end
228
+
229
+ # Confirm a checkout intent and poll until it reaches a completed state.
230
+ sig do
231
+ params(
232
+ id: String,
233
+ params: T::Hash[Symbol, T.anything],
234
+ poll_interval: Float,
235
+ max_attempts: Integer
236
+ ).returns(
237
+ T.any(
238
+ ::CheckoutIntents::CheckoutIntent::CompletedCheckoutIntent,
239
+ ::CheckoutIntents::CheckoutIntent::FailedCheckoutIntent
240
+ )
241
+ )
242
+ end
243
+ def confirm_and_poll(
244
+ id,
245
+ params,
246
+ poll_interval: DEFAULT_POLL_INTERVAL,
247
+ max_attempts: DEFAULT_MAX_ATTEMPTS
248
+ )
249
+ end
250
+
159
251
  # @api private
160
252
  sig { params(client: ::CheckoutIntents::Client).returns(T.attached_class) }
161
253
  def self.new(client:)
metadata CHANGED
@@ -1,15 +1,29 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: checkout-intents
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.2
4
+ version: 0.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Checkout Intents
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-01-07 00:00:00.000000000 Z
11
+ date: 2026-01-12 00:00:00.000000000 Z
12
12
  dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: cgi
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
13
27
  - !ruby/object:Gem::Dependency
14
28
  name: connection_pool
15
29
  requirement: !ruby/object:Gem::Requirement