-
Notifications
You must be signed in to change notification settings - Fork 552
/
api_requestor.rb
1131 lines (964 loc) · 43.8 KB
/
api_requestor.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# frozen_string_literal: true
require "stripe/instrumentation"
module Stripe
# APIRequestor executes requests against the Stripe API and allows a user to
# recover both a resource a call returns as well as a response object that
# contains information on the HTTP call.
class APIRequestor
# A set of all known thread contexts across all threads and a mutex to
# synchronize global access to them.
@thread_contexts_with_connection_managers = Set.new
@thread_contexts_with_connection_managers_mutex = Mutex.new
@last_connection_manager_gc = Util.monotonic_time
# Initializes a new APIRequestor
def initialize(config_arg = {})
@system_profiler = SystemProfiler.new
@last_request_metrics = Queue.new
@config = case config_arg
when Hash
StripeConfiguration.new.reverse_duplicate_merge(config_arg)
when Stripe::StripeConfiguration
config_arg
when String
StripeConfiguration.new.reverse_duplicate_merge(
{ api_key: config_arg }
)
else
raise ArgumentError, "Can't handle argument: #{config_arg}"
end
end
attr_reader :config, :options
# Gets a currently active `APIRequestor`. Set for the current thread when
# `APIRequestor#request` is being run so that API operations being executed
# inside of that block can find the currently active requestor. It's reset to
# the original value (hopefully `nil`) after the block ends.
#
# For internal use only. Does not provide a stable API and may be broken
# with future non-major changes.
def self.active_requestor
current_thread_context.active_requestor || default_requestor
end
# Finishes any active connections by closing their TCP connection and
# clears them from internal tracking in all connection managers across all
# threads.
#
# If passed a `config` object, only clear connection managers for that
# particular configuration.
#
# For internal use only. Does not provide a stable API and may be broken
# with future non-major changes.
def self.clear_all_connection_managers(config: nil)
# Just a quick path for when configuration is being set for the first
# time before any connections have been opened. There is technically some
# potential for thread raciness here, but not in a practical sense.
return if @thread_contexts_with_connection_managers.empty?
@thread_contexts_with_connection_managers_mutex.synchronize do
pruned_contexts = Set.new
@thread_contexts_with_connection_managers.each do |thread_context|
# Note that the thread context itself is not destroyed, but we clear
# its connection manager and remove our reference to it. If it ever
# makes a new request we'll give it a new connection manager and
# it'll go back into `@thread_contexts_with_connection_managers`.
thread_context.default_connection_managers.reject! do |cm_config, cm|
if config.nil? || config.key == cm_config
cm.clear
true
end
end
pruned_contexts << thread_context if thread_context.default_connection_managers.empty?
end
@thread_contexts_with_connection_managers.subtract(pruned_contexts)
end
end
# A default requestor for the current thread.
def self.default_requestor
current_thread_context.default_requestor ||= APIRequestor.new(Stripe.config)
end
# A default connection manager for the current thread scoped to the
# configuration object that may be provided.
def self.default_connection_manager(config = Stripe.config)
current_thread_context.default_connection_managers[config.key] ||= begin
connection_manager = ConnectionManager.new(config)
@thread_contexts_with_connection_managers_mutex.synchronize do
maybe_gc_connection_managers
@thread_contexts_with_connection_managers << current_thread_context
end
connection_manager
end
end
# Checks if an error is a problem that we should retry on. This includes
# both socket errors that may represent an intermittent problem and some
# special HTTP statuses.
def self.should_retry?(error,
num_retries:, config: Stripe.config)
return false if num_retries >= config.max_network_retries
case error
when Net::OpenTimeout, Net::ReadTimeout
# Retry on timeout-related problems (either on open or read).
true
when EOFError, Errno::ECONNREFUSED, Errno::ECONNRESET, # rubocop:todo Lint/DuplicateBranch
Errno::EHOSTUNREACH, Errno::ETIMEDOUT, SocketError
# Destination refused the connection, the connection was reset, or a
# variety of other connection failures. This could occur from a single
# saturated server, so retry in case it's intermittent.
true
when Stripe::StripeError
# The API may ask us not to retry (e.g. if doing so would be a no-op),
# or advise us to retry (e.g. in cases of lock timeouts). Defer to
# those instructions if given.
return false if error.http_headers["stripe-should-retry"] == "false"
return true if error.http_headers["stripe-should-retry"] == "true"
# 409 Conflict
return true if error.http_status == 409
# 429 Too Many Requests
#
# There are a few different problems that can lead to a 429. The most
# common is rate limiting, on which we *don't* want to retry because
# that'd likely contribute to more contention problems. However, some
# 429s are lock timeouts, which is when a request conflicted with
# another request or an internal process on some particular object.
# These 429s are safe to retry.
return true if error.http_status == 429 && error.code == "lock_timeout"
# Retry on 500, 503, and other internal errors.
#
# Note that we expect the stripe-should-retry header to be false
# in most cases when a 500 is returned, since our idempotency framework
# would typically replay it anyway.
true if error.http_status >= 500
else
false
end
end
def self.sleep_time(num_retries, config: Stripe.config)
# Apply exponential backoff with initial_network_retry_delay on the
# number of num_retries so far as inputs. Do not allow the number to
# exceed max_network_retry_delay.
sleep_seconds = [
config.initial_network_retry_delay * (2**(num_retries - 1)),
config.max_network_retry_delay,
].min
# Apply some jitter by randomizing the value in the range of
# (sleep_seconds / 2) to (sleep_seconds).
sleep_seconds *= (0.5 * (1 + rand))
# But never sleep less than the base sleep seconds.
[config.initial_network_retry_delay, sleep_seconds].max
end
# Executes the API call within the given block. Usage looks like:
#
# client = APIRequestor.new
# charge, resp = client.request { Charge.create }
#
def request
old_api_requestor = self.class.current_thread_context.active_requestor
self.class.current_thread_context.active_requestor = self
if self.class.current_thread_context.last_responses&.key?(object_id)
raise "calls to APIRequestor#request cannot be nested within a thread"
end
self.class.current_thread_context.last_responses ||= {}
self.class.current_thread_context.last_responses[object_id] = nil
begin
res = yield
[res, self.class.current_thread_context.last_responses[object_id]]
ensure
self.class.current_thread_context.active_requestor = old_api_requestor
self.class.current_thread_context.last_responses.delete(object_id)
end
end
extend Gem::Deprecate
deprecate :request, "StripeClient#raw_request", 2024, 9
def execute_request(method, path, base_address,
params: {}, opts: {}, usage: [])
http_resp, req_opts = execute_request_internal(
method, path, base_address, params, opts, usage
)
req_opts = RequestOptions.extract_opts_from_hash(req_opts)
resp = interpret_response(http_resp)
# If being called from `APIRequestor#request`, put the last response in
# thread-local memory so that it can be returned to the user. Don't store
# anything otherwise so that we don't leak memory.
store_last_response(object_id, resp)
api_mode = Util.get_api_mode(path)
Util.convert_to_stripe_object_with_params(resp.data, params, RequestOptions.persistable(req_opts), resp,
api_mode: api_mode, requestor: self)
end
# Execute request without instantiating a new object if the relevant object's name matches the class
#
# For internal use only. Does not provide a stable API and may be broken
# with future non-major changes.
def execute_request_initialize_from(method, path, base_address, object,
params: {}, opts: {}, usage: [])
opts = RequestOptions.combine_opts(object.instance_variable_get(:@opts), opts)
opts = Util.normalize_opts(opts)
http_resp, req_opts = execute_request_internal(
method, path, base_address, params, opts, usage
)
req_opts = RequestOptions.extract_opts_from_hash(req_opts)
resp = interpret_response(http_resp)
# If being called from `APIRequestor#request`, put the last response in
# thread-local memory so that it can be returned to the user. Don't store
# anything otherwise so that we don't leak memory.
store_last_response(object_id, resp)
if Util.object_name_matches_class?(resp.data[:object], object.class)
object.send(:initialize_from,
resp.data, RequestOptions.persistable(req_opts), resp,
api_mode: :v1, requestor: self)
else
Util.convert_to_stripe_object_with_params(resp.data, params,
RequestOptions.persistable(req_opts),
resp, api_mode: :v1, requestor: self)
end
end
def interpret_response(http_resp)
StripeResponse.from_net_http(http_resp)
rescue JSON::ParserError
raise general_api_error(http_resp.code.to_i, http_resp.body)
end
# Executes a request and returns the body as a stream instead of converting
# it to a StripeObject. This should be used for any request where we expect
# an arbitrary binary response.
#
# A `read_body_chunk` block can be passed, which will be called repeatedly
# with the body chunks read from the socket.
#
# If a block is passed, a StripeHeadersOnlyResponse is returned as the
# block is expected to do all the necessary body processing. If no block is
# passed, then a StripeStreamResponse is returned containing an IO stream
# with the response body.
def execute_request_stream(method, path,
base_address,
params: {}, opts: {}, usage: [],
&read_body_chunk_block)
unless block_given?
raise ArgumentError,
"execute_request_stream requires a read_body_chunk_block"
end
http_resp, api_key = execute_request_internal(
method, path, base_address, params, opts, usage, &read_body_chunk_block
)
# When the read_body_chunk_block is given, we no longer have access to the
# response body at this point and so return a response object containing
# only the headers. This is because the body was consumed by the block.
resp = StripeHeadersOnlyResponse.from_net_http(http_resp)
[resp, api_key]
end
def store_last_response(object_id, resp)
return unless last_response_has_key?(object_id)
self.class.current_thread_context.last_responses[object_id] = resp
end
def last_response_has_key?(object_id)
self.class.current_thread_context.last_responses&.key?(object_id)
end
#
# private
#
# Time (in seconds) that a connection manager has not been used before it's
# eligible for garbage collection.
CONNECTION_MANAGER_GC_LAST_USED_EXPIRY = 120
# How often to check (in seconds) for connection managers that haven't been
# used in a long time and which should be garbage collected.
CONNECTION_MANAGER_GC_PERIOD = 60
ERROR_MESSAGE_CONNECTION =
"Unexpected error communicating when trying to connect to " \
"Stripe (%s). You may be seeing this message because your DNS is not " \
"working or you don't have an internet connection. To check, try " \
"running `host stripe.com` from the command line."
ERROR_MESSAGE_SSL =
"Could not establish a secure connection to Stripe (%s), you " \
"may need to upgrade your OpenSSL version. To check, try running " \
"`openssl s_client -connect api.stripe.com:443` from the command " \
"line."
# Common error suffix sared by both connect and read timeout messages.
ERROR_MESSAGE_TIMEOUT_SUFFIX =
"Please check your internet connection and try again. " \
"If this problem persists, you should check Stripe's service " \
"status at https://status.stripe.com, or let us know at " \
ERROR_MESSAGE_TIMEOUT_CONNECT = (
"Timed out connecting to Stripe (%s). " +
ERROR_MESSAGE_TIMEOUT_SUFFIX
).freeze
ERROR_MESSAGE_TIMEOUT_READ = (
"Timed out communicating with Stripe (%s). " +
ERROR_MESSAGE_TIMEOUT_SUFFIX
).freeze
# Maps types of exceptions that we're likely to see during a network
# request to more user-friendly messages that we put in front of people.
# The original error message is also appended onto the final exception for
# full transparency.
NETWORK_ERROR_MESSAGES_MAP = {
EOFError => ERROR_MESSAGE_CONNECTION,
Errno::ECONNREFUSED => ERROR_MESSAGE_CONNECTION,
Errno::ECONNRESET => ERROR_MESSAGE_CONNECTION,
Errno::EHOSTUNREACH => ERROR_MESSAGE_CONNECTION,
Errno::ETIMEDOUT => ERROR_MESSAGE_TIMEOUT_CONNECT,
SocketError => ERROR_MESSAGE_CONNECTION,
Net::OpenTimeout => ERROR_MESSAGE_TIMEOUT_CONNECT,
Net::ReadTimeout => ERROR_MESSAGE_TIMEOUT_READ,
OpenSSL::SSL::SSLError => ERROR_MESSAGE_SSL,
}.freeze
private_constant :NETWORK_ERROR_MESSAGES_MAP
# A record representing any data that `APIRequestor` puts into
# `Thread.current`. Making it a class likes this gives us a little extra
# type safety and lets us document what each field does.
#
# For internal use only. Does not provide a stable API and may be broken
# with future non-major changes.
class ThreadContext
# A `APIRequestor` that's been flagged as currently active within a
# thread by `APIRequestor#request`. A requestor stays active until the
# completion of the request block.
attr_accessor :active_requestor
# A default `APIRequestor` object for the thread. Used in all cases where
# the user hasn't specified their own.
attr_accessor :default_requestor
# A temporary map of object IDs to responses from last executed API
# calls. Used to return a responses from calls to `APIRequestor#request`.
#
# Stored in the thread data to make the use of a single `APIRequestor`
# object safe across multiple threads. Stored as a map so that multiple
# `APIRequestor` objects can run concurrently on the same thread.
#
# Responses are only left in as long as they're needed, which means
# they're removed as soon as a call leaves `APIRequestor#request`, and
# because that's wrapped in an `ensure` block, they should never leave
# garbage in `Thread.current`.
attr_accessor :last_responses
# A map of connection mangers for the thread. Normally shared between
# all `APIRequestor` objects on a particular thread, and created so as to
# minimize the number of open connections that an application needs.
def default_connection_managers
@default_connection_managers ||= {}
end
def reset_connection_managers
@default_connection_managers = {}
end
end
# Access data stored for `APIRequestor` within the thread's current
# context. Returns `ThreadContext`.
#
# For internal use only. Does not provide a stable API and may be broken
# with future non-major changes.
def self.current_thread_context
Thread.current[:api_requestor__internal_use_only] ||= ThreadContext.new
end
# Garbage collects connection managers that haven't been used in some time,
# with the idea being that we want to remove old connection managers that
# belong to dead threads and the like.
#
# Prefixed with `maybe_` because garbage collection will only run
# periodically so that we're not constantly engaged in busy work. If
# connection managers live a little passed their useful age it's not
# harmful, so it's not necessary to get them right away.
#
# For testability, returns `nil` if it didn't run and the number of
# connection managers that were garbage collected otherwise.
#
# IMPORTANT: This method is not thread-safe and expects to be called inside
# a lock on `@thread_contexts_with_connection_managers_mutex`.
#
# For internal use only. Does not provide a stable API and may be broken
# with future non-major changes.
def self.maybe_gc_connection_managers
next_gc_time = @last_connection_manager_gc + CONNECTION_MANAGER_GC_PERIOD
return nil if next_gc_time > Util.monotonic_time
last_used_threshold =
Util.monotonic_time - CONNECTION_MANAGER_GC_LAST_USED_EXPIRY
pruned_contexts = []
@thread_contexts_with_connection_managers.each do |thread_context|
thread_context
.default_connection_managers
.each do |config_key, connection_manager|
next if connection_manager.last_used > last_used_threshold
connection_manager.clear
thread_context.default_connection_managers.delete(config_key)
end
end
@thread_contexts_with_connection_managers.each do |thread_context|
next unless thread_context.default_connection_managers.empty?
pruned_contexts << thread_context
end
@thread_contexts_with_connection_managers -= pruned_contexts
@last_connection_manager_gc = Util.monotonic_time
pruned_contexts.count
end
private def execute_request_internal(method, path,
base_address, params, opts, usage,
&read_body_chunk_block)
api_mode = Util.get_api_mode(path)
opts = RequestOptions.merge_config_and_opts(config, opts)
raise ArgumentError, "method should be a symbol" \
unless method.is_a?(Symbol)
raise ArgumentError, "path should be a string" \
unless path.is_a?(String)
base_url ||= config.base_addresses[base_address]
raise ArgumentError, "api_base cannot be empty" if base_url.nil? || base_url.empty?
api_key ||= opts[:api_key]
params = Util.objects_to_ids(params)
check_api_key!(api_key)
body_params = nil
query_params = nil
case method
when :get, :head, :delete
query_params = params
else
body_params = params
end
query_params, path = merge_query_params(query_params, path)
headers = request_headers(method, api_mode, opts)
url = api_url(path, base_url)
# Merge given query parameters with any already encoded in the path.
query = query_params ? Util.encode_parameters(query_params, api_mode) : nil
# Encoding body parameters is a little more complex because we may have
# to send a multipart-encoded body. `body_log` is produced separately as
# a log-friendly variant of the encoded form. File objects are displayed
# as such instead of as their file contents.
body, body_log =
body_params ? encode_body(body_params, headers, api_mode) : [nil, nil]
# stores information on the request we're about to make so that we don't
# have to pass as many parameters around for logging.
context = RequestLogContext.new
context.account = headers["Stripe-Account"]
context.api_key = api_key
context.api_version = headers["Stripe-Version"]
context.body = body_log
context.idempotency_key = headers["Idempotency-Key"]
context.method = method
context.path = path
context.query = query
# A block can be passed in to read the content directly from the response.
# We want to execute this block only when the response was actually
# successful. When it wasn't, we defer to the standard error handling as
# we have to read the body and parse the error JSON.
response_block =
if block_given?
lambda do |response|
response.read_body(&read_body_chunk_block) unless should_handle_as_error(response.code.to_i)
end
end
http_resp =
execute_request_with_rescues(base_url, headers, api_mode, usage, context) do
self.class
.default_connection_manager(config)
.execute_request(method, url,
body: body,
headers: headers,
query: query,
&response_block)
end
[http_resp, opts]
end
private def api_url(url, base_url)
base_url + url
end
private def check_api_key!(api_key)
unless api_key
raise AuthenticationError, "No API key provided. " \
'Set your API key using "Stripe.api_key = <API-KEY>". ' \
"You can generate API keys from the Stripe web interface. " \
"See https://stripe.com/api for details, or email " \
"[email protected] if you have any questions."
end
return unless api_key =~ /\s/
raise AuthenticationError, "Your API key is invalid, as it contains " \
"whitespace. (HINT: You can double-check your API key from the " \
"Stripe web interface. See https://stripe.com/api for details, or " \
"email [email protected] if you have any questions.)"
end
# Encodes a set of body parameters using multipart if `Content-Type` is set
# for that, or standard form-encoding otherwise. Returns the encoded body
# and a version of the encoded body that's safe to be logged.
private def encode_body(body_params, headers, api_mode)
body = nil
flattened_params = Util.flatten_params(body_params, api_mode)
if headers["Content-Type"] == MultipartEncoder::MULTIPART_FORM_DATA
body, content_type = MultipartEncoder.encode(flattened_params)
# Set a new content type that also includes the multipart boundary.
# See `MultipartEncoder` for details.
headers["Content-Type"] = content_type
# `#to_s` any complex objects like files and the like to build output
# that's more condusive to logging.
flattened_params =
flattened_params.map { |k, v| [k, v.is_a?(String) ? v : v.to_s] }.to_h
elsif api_mode == :v2
body = JSON.generate(body_params)
headers["Content-Type"] = "application/json"
else
body = Util.encode_parameters(body_params, api_mode)
headers["Content-Type"] = "application/x-www-form-urlencoded"
end
body_log = if api_mode == :v2
body
else
# We don't use `Util.encode_parameters` partly as an optimization (to
# not redo work we've already done), and partly because the encoded
# forms of certain characters introduce a lot of visual noise and it's
# nice to have a clearer format for logs.
flattened_params.map { |k, v| "#{k}=#{v}" }.join("&")
end
[body, body_log]
end
private def should_handle_as_error(http_status)
http_status >= 400
end
private def execute_request_with_rescues(base_url, headers, api_mode, usage, context)
num_retries = 0
begin
request_start = nil
user_data = nil
log_request(context, num_retries)
user_data = notify_request_begin(context)
request_start = Util.monotonic_time
resp = yield
request_duration = Util.monotonic_time - request_start
http_status = resp.code.to_i
context = context.dup_from_response_headers(resp)
handle_error_response(resp, context, api_mode) if should_handle_as_error(http_status)
log_response(context, request_start, http_status, resp.body, resp)
notify_request_end(context, request_duration, http_status,
num_retries, user_data, resp, headers)
if config.enable_telemetry? && context.request_id
request_duration_ms = (request_duration * 1000).to_i
@last_request_metrics << StripeRequestMetrics.new(context.request_id, request_duration_ms, usage: usage)
end
# We rescue all exceptions from a request so that we have an easy spot to
# implement our retry logic across the board. We'll re-raise if it's a
# type of exception that we didn't expect to handle.
rescue StandardError => e
# If we modify context we copy it into a new variable so as not to
# taint the original on a retry.
error_context = context
http_status = nil
request_duration = Util.monotonic_time - request_start if request_start
if e.is_a?(Stripe::StripeError)
error_context = context.dup_from_response_headers(e.http_headers)
http_status = resp.code.to_i
log_response(error_context, request_start,
e.http_status, e.http_body, resp)
else
log_response_error(error_context, request_start, e)
end
notify_request_end(context, request_duration, http_status, num_retries,
user_data, resp, headers)
if self.class.should_retry?(e,
num_retries: num_retries,
config: config)
num_retries += 1
sleep self.class.sleep_time(num_retries, config: config)
retry
end
case e
when Stripe::StripeError
raise
when *NETWORK_ERROR_MESSAGES_MAP.keys
handle_network_error(e, error_context, num_retries, base_url)
# Only handle errors when we know we can do so, and re-raise otherwise.
# This should be pretty infrequent.
else # rubocop:todo Lint/DuplicateBranch
raise
end
end
resp
end
private def notify_request_begin(context)
return unless Instrumentation.any_subscribers?(:request_begin)
event = Instrumentation::RequestBeginEvent.new(
method: context.method,
path: context.path,
user_data: {}
)
Stripe::Instrumentation.notify(:request_begin, event)
# This field may be set in the `request_begin` callback. If so, we'll
# forward it onto `request_end`.
event.user_data
end
private def notify_request_end(context, duration, http_status, num_retries,
user_data, resp, headers)
return if !Instrumentation.any_subscribers?(:request_end) &&
!Instrumentation.any_subscribers?(:request)
request_context = Stripe::Instrumentation::RequestContext.new(
duration: duration,
context: context,
header: headers
)
response_context = Stripe::Instrumentation::ResponseContext.new(
http_status: http_status,
response: resp
)
event = Instrumentation::RequestEndEvent.new(
request_context: request_context,
response_context: response_context,
num_retries: num_retries,
user_data: user_data || {}
)
Stripe::Instrumentation.notify(:request_end, event)
# The name before `request_begin` was also added. Provided for backwards
# compatibility.
Stripe::Instrumentation.notify(:request, event)
end
private def general_api_error(status, body)
APIError.new("Invalid response object from API: #{body.inspect} " \
"(HTTP response code was #{status})",
http_status: status, http_body: body)
end
# Formats a plugin "app info" hash into a string that we can tack onto the
# end of a User-Agent string where it'll be fairly prominent in places like
# the Dashboard. Note that this formatting has been implemented to match
# other libraries, and shouldn't be changed without universal consensus.
private def format_app_info(info)
str = info[:name]
str = "#{str}/#{info[:version]}" unless info[:version].nil?
str = "#{str} (#{info[:url]})" unless info[:url].nil?
str
end
private def handle_error_response(http_resp, context, api_mode)
begin
resp = StripeResponse.from_net_http(http_resp)
error_data = resp.data[:error]
raise StripeError, "Indeterminate error" unless error_data
rescue JSON::ParserError, StripeError
raise general_api_error(http_resp.code.to_i, http_resp.body)
end
error = if error_data.is_a?(String)
specific_oauth_error(resp, error_data, context)
elsif api_mode == :v2
specific_v2_api_error(resp, error_data, context)
else
specific_api_error(resp, error_data, context)
end
error.response = resp
raise(error)
end
# Works around an edge case where we end up with both query parameters from
# parameteers and query parameters that were appended onto the end of the
# given path.
#
# Decode any parameters that were added onto the end of a path and add them
# to a unified query parameter hash so that all parameters end up in one
# place and all of them are correctly included in the final request.
private def merge_query_params(query_params, path)
u = URI.parse(path)
# Return original results if there was nothing to be found.
return query_params, path if u.query.nil?
query_params ||= {}
query_params = Hash[URI.decode_www_form(u.query)].merge(query_params)
# Reset the path minus any query parameters that were specified.
path = u.path
[query_params, path]
end
private def specific_api_error(resp, error_data, context)
Util.log_error("Stripe API error",
status: resp.http_status,
error_code: error_data[:code],
error_message: error_data[:message],
error_param: error_data[:param],
error_type: error_data[:type],
idempotency_key: context.idempotency_key,
request_id: context.request_id,
config: config)
# The standard set of arguments that can be used to initialize most of
# the exceptions.
opts = {
http_body: resp.http_body,
http_headers: resp.http_headers,
http_status: resp.http_status,
json_body: resp.data,
code: error_data[:code],
}
case resp.http_status
when 400, 404
case error_data[:type]
when "idempotency_error"
IdempotencyError.new(error_data[:message], **opts)
else
InvalidRequestError.new(
error_data[:message], error_data[:param],
**opts
)
end
when 401
AuthenticationError.new(error_data[:message], **opts)
when 402
CardError.new(
error_data[:message], error_data[:param],
**opts
)
when 403
PermissionError.new(error_data[:message], **opts)
when 429
RateLimitError.new(error_data[:message], **opts)
else
APIError.new(error_data[:message], **opts)
end
end
private def specific_v2_api_error(resp, error_data, context)
Util.log_error("Stripe v2 API error",
status: resp.http_status,
error_code: error_data[:code],
error_message: error_data[:message],
error_param: error_data[:param],
error_type: error_data[:type],
idempotency_key: context.idempotency_key,
request_id: context.request_id,
config: config)
# The standard set of arguments that can be used to initialize most of
# the exceptions.
opts = {
http_body: resp.http_body,
http_headers: resp.http_headers,
http_status: resp.http_status,
json_body: resp.data,
code: error_data[:code],
}
case error_data[:type]
when "idempotency_error"
IdempotencyError.new(error_data[:message], **opts)
# switch cases: The beginning of the section generated from our OpenAPI spec
when "temporary_session_expired"
TemporarySessionExpiredError.new(error_data[:message], **opts)
# switch cases: The end of the section generated from our OpenAPI spec
else
specific_api_error(resp, error_data, context)
end
end
# Attempts to look at a response's error code and return an OAuth error if
# one matches. Will return `nil` if the code isn't recognized.
private def specific_oauth_error(resp, error_code, context)
description = resp.data[:error_description] || error_code
Util.log_error("Stripe OAuth error",
status: resp.http_status,
error_code: error_code,
error_description: description,
idempotency_key: context.idempotency_key,
request_id: context.request_id,
config: config)
args = {
http_status: resp.http_status, http_body: resp.http_body,
json_body: resp.data, http_headers: resp.http_headers,
}
case error_code
when "invalid_client"
OAuth::InvalidClientError.new(error_code, description, **args)
when "invalid_grant"
OAuth::InvalidGrantError.new(error_code, description, **args)
when "invalid_request"
OAuth::InvalidRequestError.new(error_code, description, **args)
when "invalid_scope"
OAuth::InvalidScopeError.new(error_code, description, **args)
when "unsupported_grant_type"
OAuth::UnsupportedGrantTypeError.new(error_code, description, **args)
when "unsupported_response_type"
OAuth::UnsupportedResponseTypeError.new(error_code, description, **args)
else
# We'd prefer that all errors are typed, but we create a generic
# OAuthError in case we run into a code that we don't recognize.
OAuth::OAuthError.new(error_code, description, **args)
end
end
private def handle_network_error(error, context, num_retries,
base_url)
Util.log_error("Stripe network error",
error_message: error.message,
idempotency_key: context.idempotency_key,
request_id: context.request_id,
config: config)
errors, message = NETWORK_ERROR_MESSAGES_MAP.detect do |(e, _)|
error.is_a?(e)
end
if errors.nil?
message = "Unexpected error #{error.class.name} communicating " \
"with Stripe. Please let us know at [email protected]."
end
message = message % base_url
message += " Request was retried #{num_retries} times." if num_retries > 0
raise APIConnectionError,
message + "\n\n(Network error: #{error.message})"
end
private def request_headers(method, api_mode, req_opts)
user_agent = "Stripe/#{api_mode} RubyBindings/#{Stripe::VERSION}"
user_agent += " " + format_app_info(Stripe.app_info) unless Stripe.app_info.nil?
headers = {
"User-Agent" => user_agent,
"Authorization" => "Bearer #{req_opts[:api_key]}",
}
if config.enable_telemetry?
begin
headers["X-Stripe-Client-Telemetry"] = JSON.generate(
last_request_metrics: @last_request_metrics.pop(true)&.payload
)
rescue ThreadError
# last_request_metrics is best effort, ignore failures when queue
# is empty. should fail if this is the first request ever sent
# on a requestor
end
end
headers["Idempotency-Key"] = req_opts[:idempotency_key] if req_opts[:idempotency_key]
# It is only safe to retry network failures on post and delete
# requests if we add an Idempotency-Key header
if %i[post delete].include?(method) && (api_mode == :v2 || config.max_network_retries > 0)
headers["Idempotency-Key"] ||= SecureRandom.uuid
end
headers["Stripe-Version"] = req_opts[:stripe_version] if req_opts[:stripe_version]
headers["Stripe-Account"] = req_opts[:stripe_account] if req_opts[:stripe_account]
headers["Stripe-Context"] = req_opts[:stripe_context] if req_opts[:stripe_context]
user_agent = @system_profiler.user_agent
begin
headers.update(
"X-Stripe-Client-User-Agent" => JSON.generate(user_agent)
)
rescue StandardError => e
headers.update(
"X-Stripe-Client-Raw-User-Agent" => user_agent.inspect,
:error => "#{e} (#{e.class})"
)
end
headers.update(req_opts[:headers])
end
private def log_request(context, num_retries)
Util.log_info("Request to Stripe API",
account: context.account,
api_version: context.api_version,
idempotency_key: context.idempotency_key,
method: context.method,
num_retries: num_retries,
path: context.path,
config: config)
Util.log_debug("Request details",
body: context.body,
idempotency_key: context.idempotency_key,
query: context.query,
config: config,
process_id: Process.pid,
thread_object_id: Thread.current.object_id,
log_timestamp: Util.monotonic_time)
end
private def log_response(context, request_start, status, body, resp)
Util.log_info("Response from Stripe API",
account: context.account,
api_version: context.api_version,
elapsed: Util.monotonic_time - request_start,
idempotency_key: context.idempotency_key,
method: context.method,
path: context.path,
request_id: context.request_id,
status: status,
config: config)
Util.log_debug("Response details",
body: body,
idempotency_key: context.idempotency_key,
request_id: context.request_id,
config: config,