[ceiling option, seed]. * The ceiling is the largest remaining + consumed ever observed, seeded * with GA4's standard-property limits. * * @var array */ public const BUCKETS = array( 'tokensPerProjectPerHour' => array( 'tagsmith_quota_ceiling', 14000 ), 'tokensPerHour' => array( 'tagsmith_quota_ceiling_hour', 40000 ), 'tokensPerDay' => array( 'tagsmith_quota_ceiling_day', 200000 ), ); /** * Whether core quota is currently low. */ public function is_low(): bool { return false !== get_transient( self::QUOTA_LOW ); } /** * The backoff transient for an API. * * @param string $api 'data', 'realtime' or 'gsc'. */ public static function backoff_key( string $api ): string { return self::BACKOFF . '_' . self::api( $api ); } /** * The consecutive-error counter option for an API. * * @param string $api 'data', 'realtime' or 'gsc'. */ public static function backoff_n_key( string $api ): string { return self::BACKOFF_N . '_' . self::api( $api ); } /** * Whether calls to an API are paused after a quota error. * * @param string $api 'data', 'realtime' or 'gsc'. */ public function is_backing_off( string $api ): bool { return false !== get_transient( self::backoff_key( $api ) ); } /** * Records one 'quota' error for an API: extends that API's backoff * exponentially and, for core Data API reports, flags quota as low for * an hour. * * @param string $api The API whose call failed: 'data', 'realtime' or 'gsc'. */ public function record_quota_error( string $api ): void { $counter = self::backoff_n_key( $api ); $n = max( 0, (int) get_option( $counter, 0 ) ) + 1; update_option( $counter, $n, false ); set_transient( self::backoff_key( $api ), 1, (int) min( 60 * 2 ** min( $n - 1, 16 ), HOUR_IN_SECONDS ) ); if ( 'data' === self::api( $api ) ) { set_transient( self::QUOTA_LOW, 1, HOUR_IN_SECONDS ); } } /** * Records a successful call to an API: resets that API's backoff * counter (deleting the option only when it exists, so the common case * costs no write query) and checks the core buckets of the response's * propertyQuota. * * @param string $api 'data', 'realtime' or 'gsc'. * @param array|null $property_quota Core propertyQuota, or null (realtime, GSC). */ public function record_success( string $api, ?array $property_quota ): void { $counter = self::backoff_n_key( $api ); if ( false !== get_option( $counter, false ) ) { delete_option( $counter ); } if ( null === $property_quota ) { return; } foreach ( self::BUCKETS as $bucket => [ $option, $seed ] ) { $tokens = $property_quota[ $bucket ] ?? null; if ( is_array( $tokens ) && isset( $tokens['remaining'] ) && self::bucket_is_low( $tokens, $option, $seed ) ) { set_transient( self::QUOTA_LOW, 1, HOUR_IN_SECONDS ); return; } } } /** * A known API name; anything else counts as the core Data API. * * @param string $api API name. */ private static function api( string $api ): string { return in_array( $api, self::APIS, true ) ? $api : 'data'; } /** * Updates a bucket's observed ceiling and reports whether it is under 10%. * * @param array $tokens ['consumed' => int, 'remaining' => int]. * @param string $option Ceiling option name. * @param int $seed Initial ceiling. */ private static function bucket_is_low( array $tokens, string $option, int $seed ): bool { $remaining = max( 0, (int) $tokens['remaining'] ); $observed = $remaining + max( 0, (int) ( $tokens['consumed'] ?? 0 ) ); $stored = (int) get_option( $option, $seed ); $ceiling = max( $stored, $observed ); if ( $ceiling !== $stored ) { update_option( $option, $ceiling, false ); } return $remaining < 0.1 * $ceiling; } }