~/blog/the-most-dangerous-button-in-an-admin-panel-is-retry
The Most Dangerous Button in an Admin Panel Is “Retry”
A practical look at why retrying a failed operation can be dangerous. This article explores uncertain outcomes, stable operation identities, concurrent retries, multi-step workflows, safe recovery actions, and how to test the failure cases that matter.
- Published
- Reading time
- 6 min
A payment request times out.
The application marks the operation as failed. A support engineer opens the admin panel, sees a red status, and clicks Retry.
The customer is charged twice.
The first payment succeeded. The application never received the response.
When I review an admin panel, I pay particular attention to buttons that repeat work. They often look like small conveniences, but they expose some of the hardest questions in the system.
A retry is safe only when we understand what the previous execution may already have changed.
The important distinction is between “the operation failed” and “we could not confirm its outcome.”
1. A failed request is not necessarily a failed operation
Consider this simplified PHP code:
try {
$receipt = $gateway->charge(
$order->totalMinor(),
$order->currency(),
);
$payments->recordSuccess($order->id(), $receipt);
} catch (NetworkTimeout $e) {
$payments->markFailed($order->id());
}The catch block records more certainty than the application has.
The provider may have rejected the request. It may never have received it. It may have completed the charge before the connection failed.
Those situations need different recovery actions.
A better starting point is to preserve the uncertainty.
try {
$receipt = $gateway->charge(
amountMinor: $attempt->amountMinor(),
currency: $attempt->currency(),
idempotencyKey: $attempt->id(),
);
} catch (NetworkTimeout $e) {
$attempts->markUnknownUnlessTerminal($attempt->id());
return;
}
$attempts->recordSuccessIdempotently(
$attempt->id(),
$receipt,
);
The collaborators are illustrative. Their names represent requirements that the implementation must enforce.
For example, a delayed timeout must not overwrite success already recorded by a webhook.
The payment attempt must also exist durably before the remote call. Otherwise, a process crash can erase the identity needed to recover it.
I am wary of systems that use one failed status for validation errors, rejected payments, network timeouts, and interrupted workers. That status does not contain enough information to choose a safe next step.
2. A retry needs the same operation identity
This code can turn every retry into a new payment:
public function retryPayment(Order $order): void
{
$this->gateway->charge(
amountMinor: $order->totalMinor(),
currency: $order->currency(),
idempotencyKey: $this->ids->generate(),
);
}The key is unique. That is precisely the problem.
The remote system sees a new operation each time.
For another execution of the same payment attempt, I reuse its identity and original parameters.
public function retryPayment(string $attemptId): void
{
$attempt = $this->attempts->get($attemptId);
$this->gateway->charge(
amountMinor: $attempt->amountMinor(),
currency: $attempt->currency(),
idempotencyKey: $attempt->id(),
);
}Reading the amount from the attempt matters. The order may have changed since the first execution.
This still depends on the provider’s idempotency contract. Key scope, parameter matching, and retention are provider-specific. A provider may eventually stop recognizing an earlier key, so reusing it is not an indefinite guarantee against duplicate effects.
A retry several days later may therefore need reconciliation before another submission.
Provider example: Stripe’s idempotent request documentation describes its parameter checks and key-retention behavior. Verify the current contract when implementing recovery.
I separate three concepts:
- The business operation: collect payment for an order.
- The payment attempt: one specific provider operation.
- The executions: network calls made to complete or resolve that attempt.
A genuinely new payment attempt gets a new identity. Repeating an uncertain execution should not silently create one.
3. A manual retry can race with an automatic one
Disabling the button after a click is useful feedback. It does not protect the operation.
A worker may already be retrying it. Another administrator may have the same screen open. A webhook may arrive while the page still shows “unknown.”
This check is insufficient:
if ($attempt->status() === 'retryable') {
$processor->process($attempt);
}Two executions can observe the same status.
I want a server-side claim that is conditional on the current state:
$claim = $attempts->claimForRecovery(
attemptId: $attemptId,
expectedVersion: $displayedVersion,
workerToken: $workerToken,
);
if ($claim === null) {
throw new OperationStateChanged();
}That repository method needs an atomic implementation, such as a conditional update with a version check.
The claim should be committed before making a slow remote call. Holding a database transaction open throughout a network request introduces another set of problems.
Claims also need recovery when a process dies. If they expire, an older execution can still be running when a new one starts.
For external effects, the stable provider operation identity remains important. A local lock alone cannot establish what happened remotely.
4. Retrying the whole workflow can repeat completed steps
Consider an order-processing job:
$reservation = $inventory->reserve($order);
$receipt = $payments->charge($order);
$shipment = $shipping->createShipment($order);
$mailer->sendConfirmation($order);If the email fails, what does Retry execute?
Running the entire function again can reserve more stock, repeat a payment, or create another shipment.
I want durable progress for the steps that matter:
$reservation = $inventory->ensureReserved(
reservationId: $workflow->reservationId(),
order: $order,
);
$receipt = $payments->ensureCollected(
attemptId: $workflow->paymentAttemptId(),
);
$shipment = $shipping->ensureCreated(
shipmentRequestId: $workflow->shipmentRequestId(),
reservation: $reservation,
);
$notifications->ensureRequested(
notificationId: $workflow->confirmationId(),
);The word ensure is not a guarantee by itself.
Each operation needs a concrete mechanism: a uniqueness constraint, a stable remote reference, provider-supported idempotency, or reconciliation.
There is still a failure window between an external effect and recording its completion locally. A boolean such as shipment_created cannot close that window on its own.
Sometimes the provider offers neither duplicate protection nor a reliable lookup. In that case, I may require manual investigation rather than expose a generic retry.
That is a legitimate outcome of the design.
5. The interface should describe the recovery action
A red status and a generic button leave the operator to infer what will happen.
I prefer an interface that distinguishes the known situations:
| Known state | Appropriate action |
|---|---|
| Local validation rejected the request before submission | Correct the input |
| Provider outcome is unknown | Check or reconcile the outcome |
| A transient failure is confirmed and repetition is safe | Retry the existing attempt |
| Payment completed; notification remains pending | Retry the notification |
| Operation is already being processed | Show current progress |
| A new business attempt is required | Start a separately authorized attempt |
The backend must enforce these distinctions. Hiding a button is not authorization or concurrency control.
For a state-changing admin action, I also expect the usual request protections, tenant scoping, and permission checks.
The audit trail should answer:
Who requested recovery? Which operation did they act on? What state did the system observe? Which execution followed? What outcome was eventually confirmed?
I want support staff to see a clear explanation such as:
“The provider outcome is unknown. Check payment status before starting another attempt.”
They should not need to interpret an exception trace to decide whether a customer might be charged again.
6. I test the missing response, not just the thrown exception
This is the test I want near payment recovery code:
// Records a successful remote effect, then loses the response.
$gateway->succeedThenTimeoutOnce();
$processor->process($attemptId);
$recovery->resolve($attemptId);
self::assertSame(
1,
$gateway->successfulChargeCount(),
);
self::assertSame(
PaymentStatus::Succeeded,
$attempts->reload($attemptId)->status(),
);The fake must model the provider’s behavior accurately. This test checks application recovery logic; it does not prove the provider contract.
I also test two recovery requests overlapping, a webhook arriving during recovery, and a process stopping after the remote effect but before the local write.
Calling the method twice sequentially does not cover those cases.
For multi-step jobs, I inject failure after each consequential step and inspect what another execution repeats.
What I Ask Before Adding the Button
Before I approve Retry, I want to know:
- What may already have completed?
- Are we repeating an execution or creating a new business attempt?
- Can another process be working on it now?
- Which identity and parameters will be reused?
- How will an uncertain outcome be resolved?
Sometimes those answers lead to a safe retry button.
Sometimes they lead to Check status, Resume processing, or Retry notification.
I want the label to tell the operator what the system will actually do—and the implementation to make that promise safe.