Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
28 changes: 27 additions & 1 deletion includes/class-tack-api-client.php
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,33 @@ public function request( $method, $path, $body = null, $timeout = null, $headers
$message = is_array( $data ) && isset( $data['message'] )
? ( is_array( $data['message'] ) ? implode( ', ', $data['message'] ) : $data['message'] )
: sprintf( /* translators: %d: HTTP status code */ __( 'TackQuote API returned HTTP %d.', 'tackquote' ), $code );
return new WP_Error( 'tack_http_' . $code, $message );

/*
* What the API SAID, kept beside the sentence, because the caller has to decide
* whether trying again can ever help (see Tack_Sync_Gate). A JSON 403 naming a
* missing scope will be refused forever; a 403 HTML page from a firewall in front
* of the API will not. Only the status and the API's own machine fields are
* kept, never the request or the key.
*/
$retry_after = wp_remote_retrieve_header( $response, 'retry-after' );
return new WP_Error(
'tack_http_' . $code,
$message,
array(
'status' => $code,
'json' => is_array( $data ),
// TackQuote's own errors echo the status in the body; a proxy's JSON does not.
'statusCode' => is_array( $data ) && isset( $data['statusCode'] ) && is_numeric( $data['statusCode'] ) ? (int) $data['statusCode'] : 0,
'code' => is_array( $data ) && isset( $data['code'] ) && is_string( $data['code'] ) ? $data['code'] : '',
'requiredScopes' => is_array( $data ) && isset( $data['requiredScopes'] ) && is_array( $data['requiredScopes'] )
? array_values( array_filter( $data['requiredScopes'], 'is_string' ) )
: array(),
'retryAfterSeconds' => is_array( $data ) && isset( $data['retryAfterSeconds'] ) && is_numeric( $data['retryAfterSeconds'] )
? (int) $data['retryAfterSeconds']
: 0,
'retryAfterHeader' => is_array( $retry_after ) ? (string) reset( $retry_after ) : (string) $retry_after,
)
);
}

return is_array( $data ) ? $data : array();
Expand Down
97 changes: 97 additions & 0 deletions includes/class-tack-order-sync.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,23 @@ class Tack_Order_Sync {
*/
const SYNC_KEY_META = '_tack_quotes_sync_key';

/**
* Action that re-queues the orders skipped while TackQuote refused order sync.
*/
const REQUEUE_HOOK = 'tack_quotes_requeue_unsynced';

/**
* Most orders one re-queue will look at. Bounded so a long outage on a busy store
* cannot enqueue thousands of jobs at once; what is left over is logged, and each of
* those orders is still pushed the next time it changes.
*/
const REQUEUE_LIMIT = 200;

/**
* How far back a re-queue reaches at most, whatever the block's start time says.
*/
const REQUEUE_MAX_AGE = 2592000; // 30 days.

/**
* Whether outbound order sync is switched on.
*
Expand Down Expand Up @@ -96,6 +113,71 @@ public function init() {
*/
public function register_worker() {
add_action( self::SYNC_HOOK, array( $this, 'run_sync' ), 10, 1 );
// Registered with the worker, not init(): a merchant who switches sync off while it
// is refused still deserves to know why orders stopped arriving.
add_action( 'admin_notices', array( 'Tack_Sync_Gate', 'render_admin_notice' ) );

// A refusal lifted (a push succeeded, or a different key was saved): re-send what
// was skipped meanwhile, off the request that lifted it.
add_action( Tack_Sync_Gate::UNBLOCKED_ACTION, array( $this, 'schedule_requeue' ), 10, 1 );
add_action( self::REQUEUE_HOOK, array( $this, 'requeue_unsynced' ), 10, 1 );
add_action( 'update_option_tack_quotes_api_key', array( 'Tack_Sync_Gate', 'on_api_key_changed' ), 10, 2 );
}

/**
* Queue one re-queue job for the orders skipped since `$since`.
*
* @param int $since Unix time pushes started being held.
*/
public function schedule_requeue( $since ) {
$args = array( (int) $since );
if ( function_exists( 'as_enqueue_async_action' ) ) {
as_enqueue_async_action( self::REQUEUE_HOOK, $args, self::SYNC_GROUP, true );
return;
}
if ( ! wp_next_scheduled( self::REQUEUE_HOOK, $args ) ) {
wp_schedule_single_event( time() + 1, self::REQUEUE_HOOK, $args );
}
}

/**
* Re-queue the orders modified since `$since`. Each goes through the normal worker,
* which skips an order whose current state was already accepted (its sync key), so
* re-queuing an order that did get through costs one meta read and no request.
*
* `date_modified => '>' . timestamp` is the documented wc_get_orders() form
* (woocommerce/docs/features/orders/wc-get-orders.md).
*
* @param int $since Unix time pushes started being held.
*/
public function requeue_unsynced( $since ) {
if ( ! self::is_enabled() || ! function_exists( 'wc_get_orders' ) ) {
return;
}
$floor = max( (int) $since - 60, time() - self::REQUEUE_MAX_AGE );
$ids = wc_get_orders(
array(
'type' => 'shop_order',
'date_modified' => '>' . $floor,
'orderby' => 'modified',
'order' => 'ASC',
'limit' => self::REQUEUE_LIMIT + 1,
'return' => 'ids',
)
);
$ids = is_array( $ids ) ? $ids : array();
if ( count( $ids ) > self::REQUEUE_LIMIT && function_exists( 'wc_get_logger' ) && wc_get_logger() ) {
wc_get_logger()->warning(
sprintf(
'TackQuote order sync resumed: re-sending the %d oldest orders changed since it was refused; later ones are sent when they next change.',
self::REQUEUE_LIMIT
),
array( 'source' => 'tackquote' )
);
}
foreach ( array_slice( $ids, 0, self::REQUEUE_LIMIT ) as $order_id ) {
$this->enqueue( (int) $order_id );
}
}

/**
Expand Down Expand Up @@ -187,10 +269,24 @@ public function run_sync( $order_id ) {
return;
}

/*
* TackQuote already refused this key in a way no retry can change (a missing
* `orders:write` scope, a revoked key, a lapsed subscription), or asked us to slow
* down. Do not send: the order is left unmarked, so its next trigger pushes it once
* the merchant has acted. See Tack_Sync_Gate for the classification and why a
* refusal used to turn into a request every few seconds.
*/
$api_key = (string) get_option( 'tack_quotes_api_key', '' );
$now = time();
if ( null !== Tack_Sync_Gate::active_block( $api_key, $now ) ) {
return;
}

$payload['idempotencyKey'] = $key;

$result = ( new Tack_Api_Client() )->sync_order( $payload, $key );
if ( is_wp_error( $result ) ) {
Tack_Sync_Gate::record_failure( $result, $api_key, $now );
if ( function_exists( 'wc_get_logger' ) ) {
wc_get_logger()->error(
sprintf(
Expand All @@ -210,6 +306,7 @@ public function run_sync( $order_id ) {
// update_post_meta(), because with HPOS enabled orders do not live in wp_postmeta.
$order->update_meta_data( self::SYNC_KEY_META, $key );
$order->save_meta_data();
Tack_Sync_Gate::record_success();
}

/**
Expand Down
1 change: 1 addition & 0 deletions includes/class-tack-quotes.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
require_once TACK_QUOTES_DIR . 'includes/class-tack-settings.php';
require_once TACK_QUOTES_DIR . 'includes/class-tack-api-client.php';
require_once TACK_QUOTES_DIR . 'includes/class-tack-widget.php';
require_once TACK_QUOTES_DIR . 'includes/class-tack-sync-gate.php';
require_once TACK_QUOTES_DIR . 'includes/class-tack-order-sync.php';
require_once TACK_QUOTES_DIR . 'includes/class-tack-catalog-mode.php';
require_once TACK_QUOTES_DIR . 'includes/class-tack-wholesale-pricing.php';
Expand Down
Loading
Loading