Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
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
91 changes: 91 additions & 0 deletions src/conditionals/gradual-rollout-conditional.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<?php

namespace Yoast\WP\SEO\Conditionals;

/**
* Feature-flag conditional whose default state is a gradual, deterministic rollout
* across a share of sites.
*
* The `YOAST_SEO_<FEATURE>` constant remains an explicit override: when it is defined
* in wp-config.php it wins outright (`true` forces the feature on, `false` forces it off),
* exactly like a plain {@see Feature_Flag_Conditional}. This is the per-site testing lever.
*
* When the constant is *not* defined, the feature falls back to the gradual-rollout
* heuristic: it is enabled for a slowly widening share of sites. A site's bucket is derived
* from a stable hash of the feature name plus the site URL, so the same site stays in (or out
* of) the rollout consistently across plugin releases.
*
* The share is expressed in per-mille (0-1000), not percent, because at the install base this
* rides on (10M+ sites) a single percent is too coarse for the first rollout steps; per-mille
* lets a rollout start at 0.1% (a share of 1).
*
* The hash input deliberately includes the feature name, so a site that buckets low for one
* feature is not automatically early for every feature - there are no permanently "lucky" sites
* that always receive new features first.
*
* This machinery is temporary by design: once a feature reaches a 100% share with no
* regressions, the concrete conditional reverts to extending {@see Feature_Flag_Conditional}
* directly and this class can be removed.
*/
abstract class Gradual_Rollout_Conditional extends Feature_Flag_Conditional {

/**
* The number of buckets sites are distributed across.
*
* @var int
*/
private const BUCKET_COUNT = 1000;

/**
* Returns whether the feature is enabled.
*
* The `YOAST_SEO_<FEATURE>` constant, when defined, is an explicit override and wins.
* Otherwise the gradual-rollout share decides.
*
* @return bool Whether the conditional is met.
*/
public function is_met() {
$constant = 'YOAST_SEO_' . \strtoupper( $this->get_feature_flag() );

// An explicit constant always wins (true forces on, false forces off).
if ( \defined( $constant ) ) {
return ( \constant( $constant ) === true );
}

return $this->is_in_rollout_cohort();
}

/**
* Returns the current rollout share in per-mille (0-1000).
*
* 0 means the feature is enabled for no sites, 1000 for all sites. The value is
* raised release over release as the rollout widens.
*
* @return int The rollout share in per-mille.
*/
abstract protected function get_rollout_share(): int;

/**
* Determines whether this site falls within the current rollout share.
*
* @return bool Whether this site is in the rollout cohort.
*/
private function is_in_rollout_cohort(): bool {
$share = \max( 0, \min( self::BUCKET_COUNT, $this->get_rollout_share() ) );

if ( $share <= 0 ) {
return false;
}

if ( $share >= self::BUCKET_COUNT ) {
return true;
}

// Hash the feature name together with the site URL so cohorts differ per feature
// (no permanently lucky sites). sprintf( '%u' ) reads crc32's result as unsigned,
// which keeps the modulo correct on 32-bit platforms where crc32 can be negative.
$bucket = ( (int) \sprintf( '%u', \crc32( $this->get_feature_name() . \site_url() ) ) % self::BUCKET_COUNT );

return ( $bucket < $share );
}
}
20 changes: 17 additions & 3 deletions src/conditionals/myyoast-connection-conditional.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,32 @@

/**
* Feature flag conditional for the MyYoast connection (OAuth client, WP-CLI
* commands, and the key-rotation cron).
* commands, and the key-rotation cron, etc.).
*
* Enable by defining `YOAST_SEO_MYYOAST_CONNECTION` as `true` in wp-config.php.
* On top of the flag, the connection is rolled out gradually to a deterministic
* share of sites — see {@see Gradual_Rollout_Conditional}.
*/
class MyYoast_Connection_Conditional extends Feature_Flag_Conditional {
class MyYoast_Connection_Conditional extends Gradual_Rollout_Conditional {

/**
* Returns the name of the feature flag.
*
* @return string The name of the feature flag.
*/
protected function get_feature_flag() {
protected function get_feature_flag(): string {
return 'MYYOAST_CONNECTION';
}

/**
* The share of sites the connection is rolled out to, in per-mille (0-1000).
*
* Ships at 0 (no sites); raised release over release as the rollout widens.
*
* @return int The rollout share in per-mille.
*/
protected function get_rollout_share(): int {
// 1%.
return 10;
}
Comment thread
diedexx marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Needed in the folder structure.

namespace Yoast\WP\SEO\MyYoast_Client\Application\Exceptions;

/**
* Exception thrown when the server temporarily refuses new Dynamic Client
* Registrations (HTTP 503 `temporarily_unavailable`).
*
* This happens when the registration emergency brake is engaged server-side during
* a controlled rollout. It is a transient condition: the caller should fall back to
* the legacy auth path and try again later.
*
* Extends {@see Registration_Failed_Exception} so existing handlers that catch
* registration failures keep working; handlers that want to surface the suggested
* retry delay can catch this subtype and read {@see self::get_retry_after_seconds()}.
*/
class Registration_Temporarily_Unavailable_Exception extends Registration_Failed_Exception {

/**
* The server-suggested retry delay in seconds, or null when none was provided.
*
* Display-only: the plugin does not schedule or enforce a wait based on this value.
*
* @var int|null
*/
private $retry_after_seconds;

/**
* Constructs the exception.
*
* @param string $message The exception message.
* @param int|null $retry_after_seconds The server-suggested retry delay in seconds, or null when unknown.
*/
public function __construct( string $message, ?int $retry_after_seconds = null ) {
parent::__construct( $message );

$this->retry_after_seconds = $retry_after_seconds;
}

/**
* Returns the server-suggested retry delay in seconds, or null when none was provided.
*
* @return int|null The retry delay in seconds, or null.
*/
public function get_retry_after_seconds(): ?int {
return $this->retry_after_seconds;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@
use Yoast\WP\SEO\Helpers\Lock_Helper;
use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Discovery_Failed_Exception;
use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Registration_Failed_Exception;
use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Registration_Temporarily_Unavailable_Exception;
use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Server_Capability_Exception;
use Yoast\WP\SEO\MyYoast_Client\Application\Ports\Client_Registration_Interface;
use Yoast\WP\SEO\MyYoast_Client\Domain\Auth_Token_Type;
use Yoast\WP\SEO\MyYoast_Client\Domain\HTTP_Response;
use Yoast\WP\SEO\MyYoast_Client\Domain\Registered_Client;
use Yoast\WP\SEO\MyYoast_Client\Infrastructure\Crypto\Encryption;
use Yoast\WP\SEO\MyYoast_Client\Infrastructure\Crypto\Encryption_Exception;
Expand Down Expand Up @@ -468,7 +470,8 @@ private function get_option_key(): string {
*
* @return Registered_Client The registration result.
*
* @throws Registration_Failed_Exception If registration fails.
* @throws Registration_Temporarily_Unavailable_Exception If the server temporarily refuses new registrations.
* @throws Registration_Failed_Exception If registration fails.
*/
private function do_register( array $redirect_uris ): Registered_Client {
try {
Expand Down Expand Up @@ -524,6 +527,14 @@ private function do_register( array $redirect_uris ): Registered_Client {
throw new Registration_Failed_Exception( 'DCR request failed: ' . $error_message );
}

// The server temporarily refuses new registrations (rollout brake engaged).
// Surface it as a typed transient failure carrying the (display-only) retry hint.
if ( $result->get_status() === 503 && $result->get_body_value( 'error' ) === 'temporarily_unavailable' ) {
$error_message = (string) $result->get_body_value( 'error_description', 'Client registration is temporarily disabled.' );
// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message.
throw new Registration_Temporarily_Unavailable_Exception( $error_message, $this->get_retry_after_seconds( $result ) );
}

if ( $result->get_status() !== 201 ) {
$error_message = (string) $result->get_body_value( 'error_description', $result->get_body_value( 'error', '' ) );
throw new Registration_Failed_Exception(
Expand All @@ -540,6 +551,30 @@ private function do_register( array $redirect_uris ): Registered_Client {
return $this->store_credentials( $body );
}

/**
* Extracts the Retry-After delay (in seconds) from a response, if present and numeric.
*
* Display-only: used solely to tell the user when to try again; the plugin does not
* schedule or enforce a wait based on it.
*
* @param HTTP_Response $result The response to read the Retry-After header from.
*
* @return int|null The retry delay in seconds, or null when absent or non-numeric.
*/
private function get_retry_after_seconds( HTTP_Response $result ): ?int {
foreach ( $result->get_headers() as $name => $value ) {
if ( \strtolower( (string) $name ) !== 'retry-after' ) {
continue;
}

$value = \is_array( $value ) ? \reset( $value ) : $value;

return \is_numeric( $value ) ? (int) $value : null;
}

return null;
}

/**
* Strips server-assigned fields from metadata for a RFC 7592 PUT request.
*
Expand Down
6 changes: 6 additions & 0 deletions src/myyoast-client/user-interface/auth-command.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use Yoast\WP\SEO\General\User_Interface\General_Page_Integration;
use Yoast\WP\SEO\Loadable_Interface;
use Yoast\WP\SEO\Main;
use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Registration_Temporarily_Unavailable_Exception;
use Yoast\WP\SEO\MyYoast_Client\Application\MyYoast_Client;
use Yoast\WP\SEO\MyYoast_Client\Application\MyYoast_Client_Cleanup;
use Yoast\WP\SEO\MyYoast_Client\Application\Ports\Client_Registration_Interface;
Expand Down Expand Up @@ -251,6 +252,11 @@ public function register( $args = null, $assoc_args = null ): void {
try {
$redirect_uri = \get_admin_url( null, 'admin.php?page=' . General_Page_Integration::PAGE . '&yoast_myyoast_oauth_callback=1' );
$client = $this->myyoast_client->ensure_registered( [ $redirect_uri ] );
} catch ( Registration_Temporarily_Unavailable_Exception $e ) {
$retry_after = $e->get_retry_after_seconds();
$retry_hint = ( $retry_after !== null ) ? \sprintf( ' Try again in %d seconds.', $retry_after ) : ' Try again later.';
WP_CLI::error( 'Registration is temporarily unavailable.' . $retry_hint );
return;
} catch ( Exception $e ) {
WP_CLI::error( 'Registration failed: ' . $e->getMessage() );
return;
Expand Down
Loading
Loading