Skip to content

Commit a81fd09

Browse files
authored
Add rate-limit/cooldown to Stripe API requests after 401 errors (#4497)
* Use the Database_Cache as rate limit for the 401 error count * Reset the invalid key error count cache after reconnect * Invalidate Account Data cache when in 401 cooldown
1 parent fe5a047 commit a81fd09

9 files changed

+184
-1
lines changed

changelog.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@
1313
* Dev - Fix failing test cases associated with WooCommerce 10.0.x
1414
* Fix - Prevent multiple save appearance AJAX calls on Block Checkout
1515
* Fix - Fix required field error message and PHP warning for custom checkout fields that don't have a label
16+
* Update - Improve Stripe API connector logging to include request/response context
1617
* Fix - Fix fatal when processing setup intents for free subscriptions via webhooks
18+
* Fix - Prevent Stripe API calls after several consecutive 401 (Unauthorized) responses
1719

1820
= 9.7.0 - 2025-07-21 =
1921
* Update - Removes BNPL payment methods (Klarna and Affirm) when other official plugins are active

includes/class-wc-stripe-api.php

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,28 @@ class WC_Stripe_API {
1616
const ENDPOINT = 'https://api.stripe.com/v1/';
1717
const STRIPE_API_VERSION = '2024-06-20';
1818

19+
/**
20+
* The invalid API key error count cache key.
21+
*
22+
* @var string
23+
*/
24+
public const INVALID_API_KEY_ERROR_COUNT_CACHE_KEY = 'invalid_api_key_error_count';
25+
26+
/**
27+
* The invalid API key error count cache timeout.
28+
* This is the delay in seconds enforced for Stripe API calls after the consecutive error count threshold is reached.
29+
*
30+
* @var int
31+
*/
32+
protected const INVALID_API_KEY_ERROR_COUNT_CACHE_TIMEOUT = 2 * HOUR_IN_SECONDS;
33+
34+
/**
35+
* The invalid API key error count threshold.
36+
*
37+
* @var int
38+
*/
39+
protected const INVALID_API_KEY_ERROR_COUNT_THRESHOLD = 5;
40+
1941
/**
2042
* Secret API Key.
2143
*
@@ -265,6 +287,20 @@ public static function request( $request, $api = 'charges', $method = 'POST', $w
265287
* @param string $api
266288
*/
267289
public static function retrieve( $api ) {
290+
// If keep count of consecutive 401 errors, and it exceeds INVALID_API_KEY_ERROR_COUNT_THRESHOLD,
291+
// we return null until the cache expires (INVALID_API_KEY_ERROR_COUNT_CACHE_TIMEOUT) or the keys are updated.
292+
$invalid_api_key_error_count = WC_Stripe_Database_Cache::get( self::INVALID_API_KEY_ERROR_COUNT_CACHE_KEY );
293+
if ( ! empty( $invalid_api_key_error_count ) && self::INVALID_API_KEY_ERROR_COUNT_THRESHOLD <= $invalid_api_key_error_count ) {
294+
// We skip logging the error here because when there is no Account cache,
295+
// the instantiation of the UPE gateway triggers a call to this method for
296+
// every available payment method. This would result in excessive log entries
297+
// which is not useful.
298+
// We only log the error when the count exceeds the threshold for the first time.
299+
300+
// The UI expects a null response (and not an error) in case of invalid API keys.
301+
return null;
302+
}
303+
268304
WC_Stripe_Logger::log( "{$api}" );
269305

270306
$response = wp_safe_remote_get(
@@ -281,7 +317,29 @@ public static function retrieve( $api ) {
281317
// Stripe redacts API keys in the response.
282318
WC_Stripe_Logger::log( "Error: GET {$api} returned a 401" );
283319

320+
++$invalid_api_key_error_count;
321+
WC_Stripe_Database_Cache::set( self::INVALID_API_KEY_ERROR_COUNT_CACHE_KEY, $invalid_api_key_error_count, self::INVALID_API_KEY_ERROR_COUNT_CACHE_TIMEOUT );
322+
323+
if ( $invalid_api_key_error_count >= self::INVALID_API_KEY_ERROR_COUNT_THRESHOLD ) {
324+
WC_Stripe_Logger::error(
325+
'Invalid API keys request rate limit exceeded',
326+
[
327+
'count' => $invalid_api_key_error_count,
328+
'next_retry' => date_i18n( 'Y-m-d H:i:sP', time() + self::INVALID_API_KEY_ERROR_COUNT_CACHE_TIMEOUT ),
329+
]
330+
);
331+
332+
// We need to invalidate the Account Data cache here, so that the UI shows the "Connect to Stripe" button.
333+
WC_Stripe_Database_Cache::delete( WC_Stripe_Account::ACCOUNT_CACHE_KEY );
334+
}
335+
284336
return null; // The UI expects this empty response in case of invalid API keys.
337+
338+
}
339+
340+
// We got a valid, non-401 response, so clear the invalid API key count if it is present.
341+
if ( null !== $invalid_api_key_error_count ) {
342+
WC_Stripe_Database_Cache::delete( self::INVALID_API_KEY_ERROR_COUNT_CACHE_KEY );
285343
}
286344

287345
if ( is_wp_error( $response ) || empty( $response['body'] ) ) {

includes/connect/class-wc-stripe-connect.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@ private function save_stripe_keys( $result, $type = 'connect', $mode = 'live' )
180180
// Enable ECE for new connections.
181181
$this->enable_ece_in_new_accounts();
182182

183+
WC_Stripe_Database_Cache::delete( WC_Stripe_API::INVALID_API_KEY_ERROR_COUNT_CACHE_KEY );
183184
WC_Stripe_Helper::update_main_stripe_settings( $options );
184185

185186
// Similar to what we do for webhooks, we save some stats to help debug oauth problems.

readme.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,5 +124,6 @@ If you get stuck, you can ask for help in the [Plugin Forum](https://wordpress.o
124124
* Fix - Prevent multiple save appearance AJAX calls on Block Checkout
125125
* Fix - Fix required field error message and PHP warning for custom checkout fields that don't have a label
126126
* Fix - Fix fatal when processing Boleto setup intents via webhooks
127+
* Fix - Prevent Stripe API calls after several consecutive 401 (Unauthorized) responses
127128

128129
[See changelog for full details across versions](https://raw.githubusercontent.com/woocommerce/woocommerce-gateway-stripe/trunk/changelog.txt).

tests/phpunit/Admin/Migrations/WC_Stripe_Subscriptions_Repairer_Legacy_SEPA_Tokens_Test.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
use WooCommerce\Stripe\Tests\Helpers\UPE_Test_Helper;
88
use WC_Gateway_Stripe_Sepa;
99
use WC_Logger;
10+
use WC_Stripe_API;
11+
use WC_Stripe_Database_Cache;
1012
use WC_Stripe_Helper;
1113
use WC_Stripe_Subscriptions_Legacy_SEPA_Token_Update;
1214
use WC_Stripe_UPE_Payment_Gateway;
@@ -87,6 +89,16 @@ public function set_up() {
8789
WC_Stripe_Helper::update_main_stripe_settings( [ 'test_connection_type' => 'connect' ] );
8890
}
8991

92+
public function tear_down() {
93+
// The tests in this file do not mock ALL the calls to the Stripe API, and as we use mocked API keys they trigger the 401 rate-limiter,
94+
// this is not a problem for these tests as they don't depend on the reponses.
95+
//
96+
// TODO: Remove this once we've mocked all calls to the Stripe API (either using the pre_http_request filter, or by using a mocked WC_Stripe_API class).
97+
WC_Stripe_Database_Cache::delete( WC_Stripe_API::INVALID_API_KEY_ERROR_COUNT_CACHE_KEY );
98+
99+
parent::tear_down();
100+
}
101+
90102
/**
91103
* For the repair to be scheduled, WC_Subscriptions must be active, UPE must be enabled, and the action must not have been scheduled before.
92104
*

tests/phpunit/Admin/WC_REST_Stripe_Settings_Controller_GB_Test.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
use WooCommerce\Stripe\Tests\Helpers\UPE_Test_Helper;
77
use WC_Gateway_Stripe;
88
use WC_REST_Stripe_Settings_Controller;
9+
use WC_Stripe_API;
10+
use WC_Stripe_Database_Cache;
911
use WC_Stripe_Feature_Flags;
1012
use WC_Stripe_Helper;
1113
use WC_Stripe_Payment_Methods;
@@ -106,6 +108,17 @@ public function set_up() {
106108
self::$gateway = WC()->payment_gateways()->payment_gateways()[ WC_Gateway_Stripe::ID ];
107109
}
108110

111+
public function tear_down() {
112+
// The tests in this file do not mock ALL the calls to the Stripe API, and as we use mocked API keys they trigger the 401 rate-limiter,
113+
// this is not a problem for these tests as they don't depend on the reponses.
114+
//
115+
// TODO: Remove this once we've mocked all calls to the Stripe API (either using the pre_http_request filter, or by using a mocked WC_Stripe_API class).
116+
WC_Stripe_Database_Cache::delete( WC_Stripe_API::INVALID_API_KEY_ERROR_COUNT_CACHE_KEY );
117+
118+
parent::tear_down();
119+
}
120+
121+
109122
public function test_get_settings_returns_available_payment_method_ids_for_gb() {
110123
$expected_method_ids = [
111124
WC_Stripe_Payment_Methods::CARD,

tests/phpunit/PaymentMethods/WC_Stripe_UPE_Payment_Gateway_Test.php

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
use WC_Stripe_UPE_Payment_Method_Wechat_Pay;
4343
use WC_Subscriptions_Helpers;
4444
use MockAction;
45+
use WC_Stripe_API;
4546
use WooCommerce\Stripe\Tests\Helpers\WC_Helper_Order;
4647
use WooCommerce\Stripe\Tests\Helpers\WC_Helper_Token;
4748
use WooCommerce\Stripe\Tests\WC_Mock_Stripe_API_Unit_Test_Case;
@@ -247,13 +248,20 @@ public function set_up() {
247248
}
248249

249250
public function tear_down() {
250-
parent::tear_down();
251251
delete_option( WC_Stripe_Feature_Flags::LPM_ACH_FEATURE_FLAG_NAME );
252252
delete_option( WC_Stripe_Feature_Flags::LPM_ACSS_FEATURE_FLAG_NAME );
253253
delete_option( WC_Stripe_Feature_Flags::LPM_BACS_FEATURE_FLAG_NAME );
254254
delete_option( WC_Stripe_Feature_Flags::LPM_BLIK_FEATURE_FLAG_NAME );
255255
delete_option( WC_Stripe_Feature_Flags::AMAZON_PAY_FEATURE_FLAG_NAME );
256256
delete_option( WC_Stripe_Feature_Flags::LPM_BECS_DEBIT_FEATURE_FLAG_NAME );
257+
258+
// The tests in this file do not mock ALL the calls to the Stripe API, and as we use mocked API keys they trigger the 401 rate-limiter,
259+
// this is not a problem for these tests as they don't depend on the reponses.
260+
//
261+
// TODO: Remove this once we've mocked all calls to the Stripe API (either using the pre_http_request filter, or by using a mocked WC_Stripe_API class).
262+
WC_Stripe_Database_Cache::delete( WC_Stripe_API::INVALID_API_KEY_ERROR_COUNT_CACHE_KEY );
263+
264+
parent::tear_down();
257265
}
258266

259267
/**

tests/phpunit/WC_Stripe_API_Test.php

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22

33
namespace WooCommerce\Stripe\Tests;
44

5+
use ReflectionClass;
56
use WC_Stripe_API;
7+
use WC_Stripe_Database_Cache;
68
use WC_Stripe_Helper;
79
use WP_UnitTestCase;
810

@@ -37,14 +39,21 @@ public function set_up() {
3739
$stripe_settings['secret_key'] = self::LIVE_SECRET_KEY;
3840
$stripe_settings['test_secret_key'] = self::TEST_SECRET_KEY;
3941
WC_Stripe_Helper::update_main_stripe_settings( $stripe_settings );
42+
43+
// Reset the invalid API keys count cache.
44+
WC_Stripe_Database_Cache::delete( WC_Stripe_API::INVALID_API_KEY_ERROR_COUNT_CACHE_KEY );
4045
}
4146

4247
/**
4348
* Tear down environment after tests.
4449
*/
4550
public function tear_down() {
51+
// Reset the invalid API keys count cache.
52+
WC_Stripe_Database_Cache::delete( WC_Stripe_API::INVALID_API_KEY_ERROR_COUNT_CACHE_KEY );
53+
4654
WC_Stripe_Helper::delete_main_stripe_settings();
4755
WC_Stripe_API::set_secret_key( null );
56+
4857
parent::tear_down();
4958
}
5059

@@ -117,6 +126,64 @@ public function test_retrieve_makes_api_call_when_api_keys_are_valid() {
117126
remove_filter( 'pre_http_request', [ $this, 'mock_successful_response' ] );
118127
}
119128

129+
/**
130+
* Test WC_Stripe_API::retrieve() returns null without API call after raeching the max threshold.
131+
*/
132+
public function test_retrieve_returns_null_without_api_call_after_threshold() {
133+
$call_count = 0;
134+
135+
// Mock HTTP to always return 401 and increment the counter.
136+
add_filter(
137+
'pre_http_request',
138+
function () use ( &$call_count ) {
139+
$call_count++;
140+
return $this->mock_unauthorized_response();
141+
}
142+
);
143+
144+
$stripe_api_class = new ReflectionClass( WC_Stripe_API::class );
145+
$threshold = $stripe_api_class->getConstant( 'INVALID_API_KEY_ERROR_COUNT_THRESHOLD' );
146+
147+
// Call retrieve up to the threshold, each should make an HTTP call.
148+
for ( $i = 0; $i < $threshold; $i++ ) {
149+
WC_Stripe_API::retrieve( 'test_endpoint' );
150+
}
151+
$this->assertEquals( $threshold, $call_count, 'Should have made HTTP calls up to the threshold.' );
152+
153+
// Now, the next call should NOT make an HTTP call, but return null immediately.
154+
$result = WC_Stripe_API::retrieve( 'test_endpoint' );
155+
$this->assertNull( $result, 'Expected null after reaching invalid API key threshold.' );
156+
$this->assertEquals( $threshold, $call_count, 'Should not make another HTTP call after threshold is reached.' );
157+
158+
remove_all_filters( 'pre_http_request' );
159+
WC_Stripe_Database_Cache::delete( WC_Stripe_API::INVALID_API_KEY_ERROR_COUNT_CACHE_KEY );
160+
}
161+
162+
/**
163+
* Test WC_Stripe_API::retrieve() resets the invalid API key count on successful response.
164+
*/
165+
public function test_retrieve_resets_invalid_api_key_count_on_successful_response() {
166+
// 1. Mock a 401 response for the first call.
167+
add_filter( 'pre_http_request', [ $this, 'mock_unauthorized_response' ] );
168+
169+
// First call: should set the cache count to 1.
170+
WC_Stripe_API::retrieve( 'test_endpoint' );
171+
$count = WC_Stripe_Database_Cache::get( WC_Stripe_API::INVALID_API_KEY_ERROR_COUNT_CACHE_KEY );
172+
$this->assertEquals( 1, $count, 'Cache count should be 1 after first 401.' );
173+
174+
remove_all_filters( 'pre_http_request' );
175+
176+
// 2. Mock a 200 response for the second call.
177+
add_filter( 'pre_http_request', [ $this, 'mock_successful_response' ] );
178+
179+
// Second call: should delete the cache.
180+
WC_Stripe_API::retrieve( 'test_endpoint' );
181+
$count = WC_Stripe_Database_Cache::get( WC_Stripe_API::INVALID_API_KEY_ERROR_COUNT_CACHE_KEY );
182+
$this->assertNull( $count, 'Cache should be deleted after a successful response.' );
183+
184+
remove_all_filters( 'pre_http_request' );
185+
}
186+
120187
/**
121188
* Helper method to mock a successful API response.
122189
*/
@@ -129,4 +196,17 @@ public function mock_successful_response() {
129196
'body' => json_encode( 'success' ),
130197
];
131198
}
199+
200+
/**
201+
* Helper method to mock an unauthorized API response.
202+
*/
203+
public function mock_unauthorized_response() {
204+
return [
205+
'response' => [
206+
'code' => 401,
207+
'message' => 'Unauthorized',
208+
],
209+
'body' => json_encode( [ 'error' => 'invalid_api_key' ] ),
210+
];
211+
}
132212
}

tests/phpunit/WC_Stripe_Subscription_Renewal_Test.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
use WooCommerce\Stripe\Tests\Helpers\WC_Helper_Order;
1010
use WP_UnitTestCase;
1111
use MockAction;
12+
use WC_Stripe_API;
13+
use WC_Stripe_Database_Cache;
1214

1315
/**
1416
* These tests assert various things about processing a renewal payment for a WooCommerce Subscription.
@@ -72,6 +74,12 @@ public function set_up() {
7274
public function tear_down() {
7375
WC_Stripe_Helper::delete_main_stripe_settings();
7476

77+
// The tests in this file do not mock ALL the calls to the Stripe API, and as we use mocked API keys they trigger the 401 rate-limiter,
78+
// this is not a problem for these tests as they don't depend on the reponses.
79+
//
80+
// TODO: Remove this once we've mocked all calls to the Stripe API (either using the pre_http_request filter, or by using a mocked WC_Stripe_API class).
81+
WC_Stripe_Database_Cache::delete( WC_Stripe_API::INVALID_API_KEY_ERROR_COUNT_CACHE_KEY );
82+
7583
parent::tear_down();
7684
}
7785

0 commit comments

Comments
 (0)