Skip to content

Add rate-limit/cooldown to Stripe API requests after 401 errors #4497

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
41de8e7
Use the Database_Cache as rate limit for the 401 error count
diegocurbelo Jul 15, 2025
1354832
Merge branch 'develop' into fix/586-disable-gateway-after-several-401…
diegocurbelo Jul 18, 2025
909c225
Merge branch 'develop' into fix/586-disable-gateway-after-several-401…
diegocurbelo Jul 21, 2025
32ffb02
Add tests
diegocurbelo Jul 21, 2025
a850c7a
Reset invalid keys cache after reconnect
diegocurbelo Jul 21, 2025
7427d30
Add changelog
diegocurbelo Jul 21, 2025
5339657
Merge branch 'develop' into fix/586-disable-gateway-after-several-401…
diegocurbelo Jul 21, 2025
c0819cd
Invalidate Account Data cache when in 401 cooldown
diegocurbelo Jul 21, 2025
1a02f9c
Only log an error once after reaching the attempt limit; and include …
diegocurbelo Jul 21, 2025
3923138
Merge branch 'develop' into fix/586-disable-gateway-after-several-401…
diegocurbelo Jul 22, 2025
8773570
Apply suggestions from code review
diegocurbelo Jul 22, 2025
dd3ea04
Rename constant prefix to INVALID_API_KEY_ERROR_COUNT
diegocurbelo Jul 22, 2025
c144129
Make the threshold and timeout constants protected
diegocurbelo Jul 22, 2025
5fda329
Merge branch 'develop' into fix/586-disable-gateway-after-several-401…
diegocurbelo Jul 22, 2025
e4a110d
Merge branch 'develop' into fix/586-disable-gateway-after-several-401…
diegocurbelo Jul 22, 2025
976886a
Merge branch 'develop' into fix/586-disable-gateway-after-several-401…
daledupreez Jul 23, 2025
08c936f
Merge branch 'develop' into fix/586-disable-gateway-after-several-401…
daledupreez Jul 23, 2025
e1be70a
Merge branch 'develop' into fix/586-disable-gateway-after-several-401…
diegocurbelo Jul 24, 2025
2c492ce
Fix tests
diegocurbelo Jul 24, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.txt
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
* Add - Add state mapping for Lithuania in express checkout
* Tweak - Use wp_ajax prefix for its built-in security for Add Payment Method action
* Dev - Fix WooCommerce version fetching in GitHub workflows
* Fix - Prevent Stripe API calls after several consecutive 401 (Unauthorized) responses

= 9.7.0 - 2025-07-21 =
* Update - Removes BNPL payment methods (Klarna and Affirm) when other official plugins are active
Expand Down
58 changes: 58 additions & 0 deletions includes/class-wc-stripe-api.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,28 @@ class WC_Stripe_API {
const ENDPOINT = 'https://api.stripe.com/v1/';
const STRIPE_API_VERSION = '2024-06-20';

/**
* The invalid API key error count cache key.
*
* @var string
*/
public const INVALID_API_KEY_ERROR_COUNT_CACHE_KEY = 'invalid_api_key_error_count';

/**
* The invalid API key error count cache timeout.
* This is the delay in seconds enforced for Stripe API calls after the consecutive error count threshold is reached.
*
* @var int
*/
protected const INVALID_API_KEY_ERROR_COUNT_CACHE_TIMEOUT = 2 * HOUR_IN_SECONDS;

/**
* The invalid API key error count threshold.
*
* @var int
*/
protected const INVALID_API_KEY_ERROR_COUNT_THRESHOLD = 5;

/**
* Secret API Key.
*
Expand Down Expand Up @@ -265,6 +287,20 @@ public static function request( $request, $api = 'charges', $method = 'POST', $w
* @param string $api
*/
public static function retrieve( $api ) {
// If keep count of consecutive 401 errors, and it exceeds INVALID_API_KEY_ERROR_COUNT_THRESHOLD,
// we return null until the cache expires (INVALID_API_KEY_ERROR_COUNT_CACHE_TIMEOUT) or the keys are updated.
$invalid_api_key_error_count = WC_Stripe_Database_Cache::get( self::INVALID_API_KEY_ERROR_COUNT_CACHE_KEY );
if ( ! empty( $invalid_api_key_error_count ) && self::INVALID_API_KEY_ERROR_COUNT_THRESHOLD <= $invalid_api_key_error_count ) {
// We skip logging the error here because when there is no Account cache,
// the instantiation of the UPE gateway triggers a call to this method for
// every available payment method. This would result in excessive log entries
// which is not useful.
// We only log the error when the count exceeds the threshold for the first time.

// The UI expects a null response (and not an error) in case of invalid API keys.
return null;
}

WC_Stripe_Logger::log( "{$api}" );

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

++$invalid_api_key_error_count;
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 );

if ( $invalid_api_key_error_count >= self::INVALID_API_KEY_ERROR_COUNT_THRESHOLD ) {
WC_Stripe_Logger::error(
'Invalid API keys request rate limit exceeded',
[
'count' => $invalid_api_key_error_count,
'next_retry' => date_i18n( 'Y-m-d H:i:sP', time() + self::INVALID_API_KEY_ERROR_COUNT_CACHE_TIMEOUT ),
]
);

// We need to invalidate the Account Data cache here, so that the UI shows the "Connect to Stripe" button.
WC_Stripe_Database_Cache::delete( WC_Stripe_Account::ACCOUNT_CACHE_KEY );
}

return null; // The UI expects this empty response in case of invalid API keys.

}

// We got a valid, non-401 response, so clear the invalid API key count if it is present.
if ( null !== $invalid_api_key_error_count ) {
WC_Stripe_Database_Cache::delete( self::INVALID_API_KEY_ERROR_COUNT_CACHE_KEY );
}
Comment on lines +341 to 343
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not blocking, but do we want to confirm we got a 2xx response code before we clear this cache? I don't think it's a big thing either way, but clearing the cache for any non-401 response feels like it may be the wrong approach in some situations, e.g. 500 or 403 responses.


if ( is_wp_error( $response ) || empty( $response['body'] ) ) {
Expand Down
1 change: 1 addition & 0 deletions includes/connect/class-wc-stripe-connect.php
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ private function save_stripe_keys( $result, $type = 'connect', $mode = 'live' )
// Enable ECE for new connections.
$this->enable_ece_in_new_accounts();

WC_Stripe_Database_Cache::delete( WC_Stripe_API::INVALID_API_KEY_ERROR_COUNT_CACHE_KEY );
WC_Stripe_Helper::update_main_stripe_settings( $options );

// Similar to what we do for webhooks, we save some stats to help debug oauth problems.
Expand Down
1 change: 1 addition & 0 deletions readme.txt
Original file line number Diff line number Diff line change
Expand Up @@ -119,5 +119,6 @@ If you get stuck, you can ask for help in the [Plugin Forum](https://wordpress.o
* Add - Add state mapping for Lithuania in express checkout
* Tweak - Use wp_ajax prefix for its built-in security for Add Payment Method action
* Dev - Fix WooCommerce version fetching in GitHub workflows
* Fix - Prevent Stripe API calls after several consecutive 401 (Unauthorized) responses

[See changelog for full details across versions](https://raw.githubusercontent.com/woocommerce/woocommerce-gateway-stripe/trunk/changelog.txt).
80 changes: 80 additions & 0 deletions tests/phpunit/WC_Stripe_API_Test.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

namespace WooCommerce\Stripe\Tests;

use ReflectionClass;
use WC_Stripe_API;
use WC_Stripe_Database_Cache;
use WC_Stripe_Helper;
use WP_UnitTestCase;

Expand Down Expand Up @@ -37,6 +39,9 @@ public function set_up() {
$stripe_settings['secret_key'] = self::LIVE_SECRET_KEY;
$stripe_settings['test_secret_key'] = self::TEST_SECRET_KEY;
WC_Stripe_Helper::update_main_stripe_settings( $stripe_settings );

// Reset the invalid API keys count cache.
WC_Stripe_Database_Cache::delete( WC_Stripe_API::INVALID_API_KEY_ERROR_COUNT_CACHE_KEY );
}

/**
Expand All @@ -45,6 +50,10 @@ public function set_up() {
public function tear_down() {
WC_Stripe_Helper::delete_main_stripe_settings();
WC_Stripe_API::set_secret_key( null );

// Reset the invalid API keys count cache.
WC_Stripe_Database_Cache::delete( WC_Stripe_API::INVALID_API_KEY_ERROR_COUNT_CACHE_KEY );

parent::tear_down();
}

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

/**
* Test WC_Stripe_API::retrieve() returns null without API call after raeching the max threshold.
*/
public function test_retrieve_returns_null_without_api_call_after_threshold() {
$call_count = 0;

// Mock HTTP to always return 401 and increment the counter.
add_filter(
'pre_http_request',
function () use ( &$call_count ) {
$call_count++;
return $this->mock_unauthorized_response();
}
);

$stripe_api_class = new ReflectionClass( WC_Stripe_API::class );
$threshold = $stripe_api_class->getConstant( 'INVALID_API_KEY_ERROR_COUNT_THRESHOLD' );

// Call retrieve up to the threshold, each should make an HTTP call.
for ( $i = 0; $i < $threshold; $i++ ) {
WC_Stripe_API::retrieve( 'test_endpoint' );
}
$this->assertEquals( $threshold, $call_count, 'Should have made HTTP calls up to the threshold.' );

// Now, the next call should NOT make an HTTP call, but return null immediately.
$result = WC_Stripe_API::retrieve( 'test_endpoint' );
$this->assertNull( $result, 'Expected null after reaching invalid API key threshold.' );
$this->assertEquals( $threshold, $call_count, 'Should not make another HTTP call after threshold is reached.' );

remove_all_filters( 'pre_http_request' );
WC_Stripe_Database_Cache::delete( WC_Stripe_API::INVALID_API_KEY_ERROR_COUNT_CACHE_KEY );
}

/**
* Test WC_Stripe_API::retrieve() resets the invalid API key count on successful response.
*/
public function test_retrieve_resets_invalid_api_key_count_on_successful_response() {
// 1. Mock a 401 response for the first call.
add_filter( 'pre_http_request', [ $this, 'mock_unauthorized_response' ] );

// First call: should set the cache count to 1.
WC_Stripe_API::retrieve( 'test_endpoint' );
$count = WC_Stripe_Database_Cache::get( WC_Stripe_API::INVALID_API_KEY_ERROR_COUNT_CACHE_KEY );
$this->assertEquals( 1, $count, 'Cache count should be 1 after first 401.' );

remove_all_filters( 'pre_http_request' );

// 2. Mock a 200 response for the second call.
add_filter( 'pre_http_request', [ $this, 'mock_successful_response' ] );

// Second call: should delete the cache.
WC_Stripe_API::retrieve( 'test_endpoint' );
$count = WC_Stripe_Database_Cache::get( WC_Stripe_API::INVALID_API_KEY_ERROR_COUNT_CACHE_KEY );
$this->assertNull( $count, 'Cache should be deleted after a successful response.' );

remove_all_filters( 'pre_http_request' );
}

/**
* Helper method to mock a successful API response.
*/
Expand All @@ -129,4 +196,17 @@ public function mock_successful_response() {
'body' => json_encode( 'success' ),
];
}

/**
* Helper method to mock an unauthorized API response.
*/
public function mock_unauthorized_response() {
return [
'response' => [
'code' => 401,
'message' => 'Unauthorized',
],
'body' => json_encode( [ 'error' => 'invalid_api_key' ] ),
];
}
}
Loading