API retries are a common way to make applications more reliable. When a request fails because of a network interruption, a temporary server problem, or a timeout, an application can retry the request instead of immediately returning an error to the user.
However, retries become more complicated when an API performs a financial operation.
Imagine a customer clicks the Pay button. Your application sends a payment request, but the connection times out before the response arrives. The payment provider may have processed the payment successfully, even though your application never received confirmation.
If the application sends the same request again without the right safeguards, the customer could be charged twice.
This is why testing retry logic requires more than checking whether a failed request is sent again. Developers must verify that retries handle uncertain outcomes correctly, that duplicate requests do not create duplicate transactions, and that the application can recover when a payment provider becomes temporarily unavailable.
In this article, we will explore how to test API retry logic, implement idempotency, simulate network failures, and verify payment behavior using practical C# examples.
Why API Retries Can Create Duplicate Payments
A typical payment workflow involves several steps:
- The application sends a payment request.
- The payment provider validates the request.
- The provider processes the payment.
- The provider returns a response.
- The application records the result and displays a confirmation.
Problems can occur when the connection fails between steps three and four.
The provider may have completed the transaction, but the application cannot determine the outcome from the missing response alone.
Consider this sequence:
- A customer submits a payment for $100.
- The payment provider processes the transaction.
- The response is lost because of a network interruption.
- The application assumes the request failed.
- The application sends another payment request.
- The provider processes the second request as a new transaction.
The customer may now have two charges.
A timeout does not necessarily mean that a payment failed. It means that the application did not receive a response within the expected period.
Your retry strategy must account for this uncertainty.
Understand Idempotency Before Implementing Retries
Idempotency is a property that allows an operation to be repeated without creating additional effects beyond the intended result.
For payment APIs, an idempotency key identifies a particular logical payment operation. If the same operation is submitted multiple times with the same key, the provider can recognize the repeated requests and avoid creating another payment.
For example, an application might generate an idempotency key when a customer starts a checkout operation:
var idempotencyKey = Guid.NewGuid().ToString("N");
The application sends that key with the payment request.
The exact header or request field depends on the payment provider. Some providers support an Idempotency-Key header, while others use a different mechanism.
The important rule is that retries for the same logical payment must reuse the original key.
Do not generate a new key every time the application retries the request. Doing so can make each retry appear to be a new payment.
Also, do not reuse one key for unrelated payments. Each new logical payment operation needs its own identity.
Idempotency support, retention periods, and behavior when the same key is submitted with different request data vary by provider. Follow the provider's documented contract rather than assuming every API implements idempotency in the same way.
Implement a Retryable Payment Request in C#
The following example demonstrates a small HTTP client that sends a payment request with an idempotency key.
It intentionally focuses on request construction and retry safety rather than implementing a complete payment system.
using System.Net.Http.Json;
public sealed record PaymentRequest(
string OrderId,
decimal Amount,
string Currency);
public sealed class PaymentClient
{
private readonly HttpClient _httpClient;
public PaymentClient(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task<HttpResponseMessage> CreatePaymentAsync(
PaymentRequest request,
string idempotencyKey,
CancellationToken cancellationToken)
{
using var message = new HttpRequestMessage(
HttpMethod.Post,
"/payments");
message.Headers.Add(
"Idempotency-Key",
idempotencyKey);
message.Content = JsonContent.Create(request);
return await _httpClient.SendAsync(
message,
cancellationToken);
}
}
The caller supplies the idempotency key rather than having the client generate a new key inside every request.
This design makes the key's lifecycle explicit. The checkout workflow creates the key once and preserves it when retrying the same payment.
In a production system, store the logical payment operation and its key durably enough to survive application restarts. If the application generates a new key after a crash, it may lose the ability to identify a previously submitted payment.
The example also leaves payment response interpretation to the caller. A production payment integration must handle provider-specific responses, authentication, request validation, and transaction-state reconciliation.
Design a Retry Policy for Transient Failures
Not every error should trigger a retry.
Some failures are temporary, while others require a change to the request or user action.
| Failure | Typical retry behavior | Reason |
|---|
| Connection reset | Consider retrying | The request outcome may be unknown |
| Request timeout | Retry safely with the same idempotency key | The provider may already have processed the payment |
| HTTP 429 | Follow the provider's retry guidance | The client may be rate-limited |
| HTTP 502 or 503 | Consider a bounded retry | The service may be temporarily unavailable |
| Invalid payment details | Do not automatically retry unchanged data | The request needs correction |
| Authentication failure | Do not blindly retry | Credentials or permissions may need attention |
| Declined payment | Follow the provider's documented behavior | Repeating the same request may not help |
| Idempotency conflict | Investigate the request and provider contract | The same key may have been reused incorrectly |
The precise behavior depends on the payment provider and its API contract.
A robust retry policy should include a maximum attempt count, a delay between attempts, and an overall time budget. Exponential backoff increases the delay between successive attempts, while jitter adds randomness to reduce synchronized retries from many clients.
For example, delays might increase from approximately 200 milliseconds to 400 milliseconds and then 800 milliseconds, with appropriate jitter and an upper limit.
Do not retry indefinitely. If the provider remains unavailable, the application should stop automatic retries and move the payment into a state that can be investigated or reconciled.
Test Retries Without Calling a Real Payment Provider
Automated tests should not create live charges or depend on a real payment service.
Instead, use a fake HTTP handler to control the responses returned to your application. This lets you simulate timeouts, transient server errors, successful responses, and other outcomes deterministically.
The following test demonstrates how to verify that two attempts use the same idempotency key.
It uses xUnit and the standard .NET HTTP abstractions.
using System.Net;
using Xunit;
public sealed class PaymentRetryTests
{
[Fact]
public async Task Retry_UsesTheSameIdempotencyKey()
{
var capturedKeys = new List<string>();
var attempt = 0;
var handler = new FakeHttpMessageHandler(
(request, cancellationToken) =>
{
capturedKeys.Add(
request.Headers.GetValues(
"Idempotency-Key").Single());
attempt++;
var statusCode = attempt == 1
? HttpStatusCode.ServiceUnavailable
: HttpStatusCode.OK;
return Task.FromResult(
new HttpResponseMessage(statusCode));
});
using var httpClient = new HttpClient(handler);
var paymentClient = new PaymentClient(httpClient);
var key = Guid.NewGuid().ToString("N");
var request = new PaymentRequest(
"ORDER-123",
100m,
"USD");
using var firstResponse =
await paymentClient.CreatePaymentAsync(
request,
key,
CancellationToken.None);
using var secondResponse =
await paymentClient.CreatePaymentAsync(
request,
key,
CancellationToken.None);
Assert.Equal(
HttpStatusCode.ServiceUnavailable,
firstResponse.StatusCode);
Assert.Equal(
HttpStatusCode.OK,
secondResponse.StatusCode);
Assert.Equal(2, capturedKeys.Count);
Assert.Equal(capturedKeys[0], capturedKeys[1]);
Assert.Equal(key, capturedKeys[0]);
}
private sealed class FakeHttpMessageHandler
: HttpMessageHandler
{
private readonly Func<
HttpRequestMessage,
CancellationToken,
Task<HttpResponseMessage>> _sendAsync;
public FakeHttpMessageHandler(
Func<
HttpRequestMessage,
CancellationToken,
Task<HttpResponseMessage>> sendAsync)
{
_sendAsync = sendAsync;
}
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
return _sendAsync(request, cancellationToken);
}
}
}
This example demonstrates the key assertion: the retry uses the same idempotency key.
It does not implement an automatic retry policy. The test invokes the client twice to make the behavior easy to understand. In a production implementation, test the retry policy itself and verify that it preserves the same key across attempts.
The fake handler also does not reproduce the behavior of a real payment provider. It verifies how your application constructs requests and reacts to responses.
Simulate the Most Important Failure Scenarios
A reliable retry test suite should cover more than a temporary HTTP error.
Test a Timeout After the Provider Processes the Payment
This is one of the most important scenarios because it exposes the difference between a failed request and an unknown payment outcome.
A realistic test double can simulate the following sequence:
- The first request reaches a fake payment service.
- The fake service records the payment under the supplied idempotency key.
- The fake service simulates a timeout before returning a response.
- The client retries with the same key.
- The fake service recognizes the existing operation and returns its original result.
The test should verify that the fake service recorded only one logical payment.
A simplified fake service might maintain a dictionary keyed by idempotency key:
public sealed class FakePaymentService
{
private readonly Dictionary<string, string> _payments = new();
public string CreateOrGetPayment(string idempotencyKey)
{
if (_payments.TryGetValue(
idempotencyKey,
out var existingPayment))
{
return existingPayment;
}
var paymentId = Guid.NewGuid().ToString("N");
_payments[idempotencyKey] = paymentId;
return paymentId;
}
public int PaymentCount => _payments.Count;
}
A test can call CreateOrGetPayment twice with the same key and assert that both calls return the same payment identifier and that PaymentCount remains one.
This fake illustrates the principle of idempotency, but it is not a replacement for testing the actual provider's behavior. It models the contract your application expects the provider to enforce.
Test Exhausted Retries
A temporary error may persist beyond the retry budget.
Verify that the application stops retrying after the configured maximum number of attempts. It should then report that the outcome is unavailable or requires reconciliation, depending on what is known.
For a payment request that timed out, do not automatically tell the customer that the payment definitely failed unless you have evidence supporting that conclusion.
Test Cancellation
A user may close the checkout page or the application may cancel an operation.
Test how cancellation interacts with retry delays and in-flight requests. The retry policy should respect cancellation rather than continuing to issue unnecessary requests.
However, cancelling an HTTP request does not guarantee that the payment provider cancels the payment. The provider may already be processing it. Your application must preserve that distinction.
Test Payment State, Not Just HTTP Status Codes
HTTP responses alone do not tell the complete story of a payment.
A payment workflow may need to represent states such as:
- Pending: The payment has been submitted, but the final outcome is not yet known.
- Succeeded: The provider has confirmed that the payment succeeded.
- Failed: The provider has confirmed a failure.
- Requires action: The customer must complete an additional verification step.
- Unknown: The application cannot currently determine the final outcome.
The exact states depend on the provider and business requirements.
Tests should verify valid transitions between states. For example, a timeout should not automatically transition a payment to Succeeded, and it should not necessarily transition it to Failed.
Where the provider supports querying payment status by payment identifier or merchant reference, test the reconciliation workflow as well.
For critical payment operations, use provider webhooks and status queries according to the provider's documented contract. Webhook handling should also be idempotent because providers may deliver the same event more than once.
Common Mistakes to Avoid
Generating a New Idempotency Key for Each Retry
A new key can cause the provider to treat a retry as a separate payment. Create the key for the logical operation and reuse it for all attempts belonging to that operation.
Retrying Every Error
Validation errors, authentication failures, and declined payments often need different handling from transient server failures.
Define retryable outcomes explicitly and follow the provider's guidance.
Assuming a Timeout Means the Payment Failed
A timeout only means that the client did not receive a response in time. The provider may have completed the transaction.
Represent uncertain outcomes explicitly and reconcile them before deciding what to tell the customer.
Testing Only the Happy Path
A test that verifies a successful payment response does not establish that retry behavior is safe.
Include tests for timeouts, repeated requests, server errors, cancellation, exhausted retries, and reconciliation.
Using Live Payment Credentials in Automated Tests
Ordinary unit and integration tests should not perform real charges or depend on production credentials.
Use fake handlers for application logic and a provider sandbox for dedicated end-to-end payment tests. Keep credentials in an appropriate secret store and use test-specific accounts.
Best Practices for Safe API Retry Testing
Use these practices when implementing and testing payment retries:
- Generate one idempotency key per logical payment operation.
- Reuse the same key for retries of that operation.
- Persist enough information to recover the operation after an application restart.
- Use bounded retries with backoff and jitter.
- Follow the provider's documented behavior for timeouts, rate limits, and repeated requests.
- Test the case where the provider processes a request but the response is lost.
- Verify that duplicate requests do not create duplicate logical payments.
- Keep payment state separate from the status of an individual HTTP request.
- Reconcile unknown outcomes using provider-supported mechanisms.
- Test webhook processing for duplicate deliveries.
- Use fake services and sandbox environments rather than live payment operations.
- Record request identifiers and relevant diagnostic information without logging sensitive payment data.
Advantages and Limitations
Testing retry behavior makes payment workflows more resilient to temporary network problems and service interruptions. It can also expose unsafe assumptions about timeouts, duplicate requests, and transaction status before they affect customers.
Fake HTTP handlers provide fast, repeatable tests, while sandbox tests help verify integration with the real provider's API contract.
However, unit tests cannot fully reproduce every network failure or guarantee that an external provider implements idempotency correctly. Sandbox environments can also differ from production in configuration and behavior.
A reliable strategy combines unit tests, retry-policy tests, provider sandbox tests, and payment-state reconciliation checks.
Summary
API retries improve resilience, but payment operations require careful handling because a missing response does not necessarily mean that a transaction failed.
Use idempotency keys to identify logical payment operations, preserve those keys across retries, and test what happens when the provider processes a request but the response never reaches your application.
With fake HTTP handlers, developers can verify retry behavior without making live payment requests. Additional tests should cover timeouts, cancellation, exhausted retries, duplicate webhooks, and reconciliation.
The goal is not simply to make requests succeed after a failure. It is to ensure that a temporary failure does not create a second payment or leave the application reporting an outcome it cannot verify.
Join the conversation! Your thoughts help the community grow.