phacility/phabricator · error · Exception

Trying to refund a charge which is already refunding!

Error message

Trying to refund a charge which is already refunding!

What it means

Inside a read-locked transaction, willRefundCharge() reloaded the charge and found refundingPHID already set, meaning another refund is already in flight for this charge. This is the single-flight concurrency guard for refund initiation. A second refund may only start after didRefundCharge() or didFailRefund() clears the flag.

Source

Thrown at src/applications/phortune/storage/PhortuneCart.php:352

    $refund_charge = PhortuneCharge::initializeNewCharge()
      ->setAccountPHID($this->getAccount()->getPHID())
      ->setCartPHID($this->getPHID())
      ->setAuthorPHID($actor->getPHID())
      ->setMerchantPHID($this->getMerchant()->getPHID())
      ->setProviderPHID($provider->getProviderConfig()->getPHID())
      ->setPaymentMethodPHID($charge->getPaymentMethodPHID())
      ->setRefundedChargePHID($charge->getPHID())
      ->setAmountAsCurrency($amount->negate());

    $charge->openTransaction();
      $charge->beginReadLocking();

        $copy = clone $charge;
        $copy->reload();

        if ($copy->getRefundingPHID() !== null) {
          throw new Exception(
            pht('Trying to refund a charge which is already refunding!'));
        }

        $refund_charge->save();
        $charge->setRefundingPHID($refund_charge->getPHID());
        $charge->save();

      $charge->endReadLocking();
    $charge->saveTransaction();

    return $refund_charge;
  }

  public function didRefundCharge(
    PhortuneCharge $charge,
    PhortuneCharge $refund) {

    $refund->setStatus(PhortuneCharge::STATUS_CHARGED);

View on GitHub (pinned to 5720a38cfe)

Solutions

  1. Catch the exception and reload the charge: if refundingPHID is set, reuse or wait for the in-flight refund rather than retrying immediately.
  2. Make refund submission idempotent in the UI (disable after click, deduplicate requests).
  3. Only retry the provider apply step on failure, never the willRefundCharge() call.

Example fix

// before
$refund = $cart->willRefundCharge($actor, $provider, $charge, $amount);
// on retry/timeout this throws 'Trying to refund a charge which is already refunding!'

// after
try {
  $refund = $cart->willRefundCharge($actor, $provider, $charge, $amount);
} catch (Exception $ex) {
  $fresh = id(new PhortuneChargeQuery())
    ->setViewer($viewer)
    ->withPHIDs(array($charge->getPHID()))
    ->executeOne();
  if ($fresh->getRefundingPHID()) {
    return; // refund already in flight; do not retry
  }
  throw $ex;
}
Defensive patterns

Strategy: try-catch

Validate before calling

$fresh = id(new PhortuneChargeQuery())
  ->setViewer($viewer)
  ->withPHIDs(array($charge->getPHID()))
  ->executeOne();
if ($fresh->getRefundingPHID() !== null) {
  // a refund is already in flight; skip or wait for it
}

Type guard

function isRefundInFlight(PhortuneCharge $charge) {
  return $charge->getRefundingPHID() !== null;
}

Try / catch

try {
  $refund = $cart->willRefundCharge($actor, $provider, $charge, $amount);
} catch (Exception $ex) {
  if (strpos($ex->getMessage(), 'already refunding') !== false) {
    // another refund holds the lock: reload and resync with its outcome
  } else {
    throw $ex;
  }
}

Prevention

When it happens

Trigger: Double-submitting a refund request (double-click, retry after a timeout); two admins or workers calling willRefundCharge() for the same charge before the first refund completes; retrying the whole refund sequence instead of just the provider step.

Common situations: Flaky network causing the browser to retry the refund POST; background jobs and UI issuing refunds concurrently; queue retries of a task that already opened a refund.

Related errors


AI-assisted analysis of phacility/phabricator@5720a38cfe (2026-08-21). Data as JSON: /api/errors/b1c9dfc685e2f668. Report an issue: GitHub.