Skip to content

Commit abdbb8c

Browse files
committed
Fix high volume of traffic to merchant-integrations endpoint on persistent seller status failures
1 parent 1a70c3a commit abdbb8c

3 files changed

Lines changed: 162 additions & 24 deletions

File tree

modules/ppcp-api-client/src/Endpoint/PartnersEndpoint.php

Lines changed: 47 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -143,38 +143,62 @@ public function seller_status(): SellerStatus {
143143
return apply_filters( 'woocommerce_paypal_payments_seller_status', $cached );
144144
}
145145

146+
/*
147+
* Back off if a recent failure was registered, to avoid hammering the
148+
* merchant-integrations endpoint on persistent errors (e.g. a 403). The
149+
* window matches the success cache TTL, and is anchored to the last real
150+
* API failure since add_failure() is only written in
151+
* fetch_seller_status_from_api() on a non-200 response.
152+
*/
153+
if ( $this->failure_registry->has_failure_in_timeframe( FailureRegistry::SELLER_STATUS_KEY, self::SELLER_STATUS_CACHE_TTL ) ) {
154+
return $this->handle_seller_status_failure(
155+
new RuntimeException( 'Seller status recently failed; backing off the merchant-integrations endpoint.' )
156+
);
157+
}
158+
146159
try {
147160
$status = $this->fetch_seller_status_from_api();
148161
} catch ( RuntimeException $exception ) {
149-
/**
150-
* Provides a fallback SellerStatus when the API call fails.
151-
*
152-
* Return a SellerStatus instance to use as fallback instead of
153-
* throwing. Return null to let the exception propagate.
154-
*
155-
* @param SellerStatus|null $fallback Default null (no fallback).
156-
*/
157-
$fallback = apply_filters( 'woocommerce_paypal_payments_seller_status_fallback', null );
162+
return $this->handle_seller_status_failure( $exception );
163+
}
158164

159-
if ( $fallback instanceof SellerStatus ) {
160-
$this->logger->log(
161-
'info',
162-
'Seller status API failed, using configured fallback.',
163-
array( 'error' => $exception->getMessage() )
164-
);
165+
$this->cache->set( self::SELLER_STATUS_CACHE_KEY, $status, self::SELLER_STATUS_CACHE_TTL );
165166

166-
$this->cache->set( self::SELLER_STATUS_CACHE_KEY, $fallback, self::SELLER_STATUS_CACHE_TTL );
167+
return apply_filters( 'woocommerce_paypal_payments_seller_status', $status );
168+
}
167169

168-
return apply_filters( 'woocommerce_paypal_payments_seller_status', $fallback );
169-
}
170+
/**
171+
* Handles a seller status failure by applying the configured fallback, or
172+
* re-throwing when no fallback is provided.
173+
*
174+
* @param RuntimeException $exception The exception describing the failure.
175+
* @return SellerStatus
176+
* @throws RuntimeException When no fallback is configured.
177+
*/
178+
private function handle_seller_status_failure( RuntimeException $exception ): SellerStatus {
179+
/**
180+
* Provides a fallback SellerStatus when the API call fails.
181+
*
182+
* Return a SellerStatus instance to use as fallback instead of
183+
* throwing. Return null to let the exception propagate.
184+
*
185+
* @param SellerStatus|null $fallback Default null (no fallback).
186+
*/
187+
$fallback = apply_filters( 'woocommerce_paypal_payments_seller_status_fallback', null );
188+
189+
if ( $fallback instanceof SellerStatus ) {
190+
$this->logger->log(
191+
'info',
192+
'Seller status API failed, using configured fallback.',
193+
array( 'error' => $exception->getMessage() )
194+
);
170195

171-
throw $exception;
172-
}
196+
$this->cache->set( self::SELLER_STATUS_CACHE_KEY, $fallback, self::SELLER_STATUS_CACHE_TTL );
173197

174-
$this->cache->set( self::SELLER_STATUS_CACHE_KEY, $status, self::SELLER_STATUS_CACHE_TTL );
198+
return apply_filters( 'woocommerce_paypal_payments_seller_status', $fallback );
199+
}
175200

176-
/** This filter is documented above. */
177-
return apply_filters( 'woocommerce_paypal_payments_seller_status', $status );
201+
throw $exception;
178202
}
179203

180204
/**

modules/ppcp-api-client/src/Helper/ProductStatus.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ protected function get_cache_lifespan( bool $is_eligible ): int {
174174
protected function get_seller_status_object(): SellerStatus {
175175
if ( null === self::$seller_status ) {
176176
// Check API failure registry to prevent multiple failed API requests.
177-
if ( $this->api_failure_registry->has_failure_in_timeframe( FailureRegistry::SELLER_STATUS_KEY, MINUTE_IN_SECONDS ) ) {
177+
if ( $this->api_failure_registry->has_failure_in_timeframe( FailureRegistry::SELLER_STATUS_KEY, PartnersEndpoint::SELLER_STATUS_CACHE_TTL ) ) {
178178
throw new RuntimeException( 'Timeout for re-check not reached yet' );
179179
}
180180

tests/PHPUnit/ApiClient/Endpoint/PartnersEndpointTest.php

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,12 @@ public function testSellerStatusOnCacheMissFetchesAndStoresResult(): void
164164
PartnersEndpoint::SELLER_STATUS_CACHE_TTL
165165
);
166166

167+
$this->failure_registry
168+
->shouldReceive('has_failure_in_timeframe')
169+
->once()
170+
->with(FailureRegistry::SELLER_STATUS_KEY, PartnersEndpoint::SELLER_STATUS_CACHE_TTL)
171+
->andReturn(false);
172+
167173
$this->seller_status_factory
168174
->shouldReceive('from_paypal_response')
169175
->once()
@@ -203,6 +209,12 @@ public function testSellerStatusOnApiErrorRegistersFailureAndThrows(): void
203209
->shouldReceive('set')
204210
->never();
205211

212+
$this->failure_registry
213+
->shouldReceive('has_failure_in_timeframe')
214+
->once()
215+
->with(FailureRegistry::SELLER_STATUS_KEY, PartnersEndpoint::SELLER_STATUS_CACHE_TTL)
216+
->andReturn(false);
217+
206218
$this->failure_registry
207219
->shouldReceive('add_failure')
208220
->once()
@@ -247,6 +259,12 @@ public function testSellerStatusOnApiErrorUsesFallbackFilter(): void
247259
PartnersEndpoint::SELLER_STATUS_CACHE_TTL
248260
);
249261

262+
$this->failure_registry
263+
->shouldReceive('has_failure_in_timeframe')
264+
->once()
265+
->with(FailureRegistry::SELLER_STATUS_KEY, PartnersEndpoint::SELLER_STATUS_CACHE_TTL)
266+
->andReturn(false);
267+
250268
$this->failure_registry
251269
->shouldReceive('add_failure')
252270
->once()
@@ -267,6 +285,102 @@ public function testSellerStatusOnApiErrorUsesFallbackFilter(): void
267285
$this->assertSame($fallback, $result);
268286
}
269287

288+
// -----------------------------------------------------------------------
289+
// Tests: seller_status() failure backoff
290+
// -----------------------------------------------------------------------
291+
292+
/**
293+
* GIVEN the transient cache is empty
294+
* AND a recent failure is registered within the backoff window
295+
* WHEN seller_status() is called
296+
* THEN no HTTP request is made to the PayPal API
297+
* AND no additional failure is registered
298+
* AND a RuntimeException is thrown (no fallback configured)
299+
*/
300+
public function testSellerStatusBacksOffWhenRecentFailureRegistered(): void
301+
{
302+
$this->cache
303+
->shouldReceive('get')
304+
->once()
305+
->with(PartnersEndpoint::SELLER_STATUS_CACHE_KEY)
306+
->andReturn(false);
307+
308+
$this->cache
309+
->shouldReceive('set')
310+
->never();
311+
312+
$this->failure_registry
313+
->shouldReceive('has_failure_in_timeframe')
314+
->once()
315+
->with(FailureRegistry::SELLER_STATUS_KEY, PartnersEndpoint::SELLER_STATUS_CACHE_TTL)
316+
->andReturn(true);
317+
318+
$this->failure_registry
319+
->shouldReceive('add_failure')
320+
->never();
321+
322+
when('apply_filters')->alias(function (string $hook, ...$args) {
323+
return $args[0] ?? null;
324+
});
325+
326+
expect('wp_remote_get')->never();
327+
328+
$this->expectException(\RuntimeException::class);
329+
330+
$this->make_endpoint()->seller_status();
331+
}
332+
333+
/**
334+
* GIVEN the transient cache is empty
335+
* AND a recent failure is registered within the backoff window
336+
* AND the fallback filter returns a SellerStatus
337+
* WHEN seller_status() is called
338+
* THEN no HTTP request is made to the PayPal API
339+
* AND the fallback is returned and stored in the cache
340+
*/
341+
public function testSellerStatusBackoffUsesFallbackWhenConfigured(): void
342+
{
343+
$fallback = Mockery::mock(SellerStatus::class);
344+
345+
$this->cache
346+
->shouldReceive('get')
347+
->once()
348+
->with(PartnersEndpoint::SELLER_STATUS_CACHE_KEY)
349+
->andReturn(false);
350+
351+
$this->cache
352+
->shouldReceive('set')
353+
->once()
354+
->with(
355+
PartnersEndpoint::SELLER_STATUS_CACHE_KEY,
356+
$fallback,
357+
PartnersEndpoint::SELLER_STATUS_CACHE_TTL
358+
);
359+
360+
$this->failure_registry
361+
->shouldReceive('has_failure_in_timeframe')
362+
->once()
363+
->with(FailureRegistry::SELLER_STATUS_KEY, PartnersEndpoint::SELLER_STATUS_CACHE_TTL)
364+
->andReturn(true);
365+
366+
$this->failure_registry
367+
->shouldReceive('add_failure')
368+
->never();
369+
370+
when('apply_filters')->alias(function (string $hook, ...$args) use ($fallback) {
371+
if ($hook === 'woocommerce_paypal_payments_seller_status_fallback') {
372+
return $fallback;
373+
}
374+
return $args[0] ?? null;
375+
});
376+
377+
expect('wp_remote_get')->never();
378+
379+
$result = $this->make_endpoint()->seller_status();
380+
381+
$this->assertSame($fallback, $result);
382+
}
383+
270384
// -----------------------------------------------------------------------
271385
// Tests: clear_seller_status_cache()
272386
// -----------------------------------------------------------------------

0 commit comments

Comments
 (0)