Every other payment failure tells you something.
A decline is an answer. A validation error is your bug. A refused connection means nothing happened.
A timeout means you don't know. The charge may have succeeded, failed, or never arrived — and one of those outcomes has the customer's money.
Most integrations treat a timeout as a failure. That's a guess. It's wrong often enough to generate a steady stream of "I was charged but my order failed" support tickets.
TL;DR
- A read timeout is ambiguous. Never mark the payment
FAILEDbecause of one. - A connect failure is different: nothing was sent, so it's safe to record as failed and retry.
- You need an explicit
UNKNOWNstate, or your code is forced to guess. - Resolve unknowns in layers — and "not found" is not "failed".
- The shortest timeout in your request chain wins, and it's usually one you forgot.
What can be true after a timeout
You can't tell these apart from your side:
| What actually happened | Customer charged? | Naive code records |
|---|---|---|
| Request never left your network | No | failed ✅ |
| Arrived, issuer declined | No | failed ✅ |
| Arrived, issuer approved, response lost | Yes | failed ❌ |
| Still in flight when you gave up | Possibly, a moment later | failed ❌ |
The third row is the expensive one: the customer sees the charge, your system has no record of it, and they contact support or dispute it. The fourth is worse — the charge completes after you've told them it failed, so they try again.
Don't manufacture your own timeouts
Before handling ambiguity better, stop creating it. Authorization has one genuinely slow step — the issuer's fraud check — and if your read timeout is shorter than that, you're turning successful payments into ambiguous ones yourself.
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5)) // can't reach them: fail fast, safe to retry
.build();
HttpRequest request = HttpRequest.newBuilder(uri)
.timeout(Duration.ofSeconds(30)) // read: generous, above realistic p99
.header("Idempotency-Key", key)
.POST(body)
.build();
- Short connect timeout. If the connection never opens, nothing was sent. Unambiguous.
- Long read timeout. Once the request is out, giving up early buys you nothing. The payment keeps processing at the other end whether you're listening or not.
The shortest timeout in the chain wins
Your client's 30 seconds is irrelevant if something in front of it gives up sooner. Check every hop:
- Load balancer or ingress idle timeout
- Reverse proxy (
proxy_read_timeoutin nginx) - Service mesh
- API gateway
- Your framework's own request timeout
- The browser, for synchronous user-facing requests
A real example: your service waits 30 seconds for the payment provider, but it sits behind an AWS API Gateway REST API, whose integration timeout defaults to 29 seconds. A payment that takes 31 seconds becomes a 504 for your caller — while the charge completes behind it. If the caller retries, you're one step away from a double charge.
It's also a strong argument for making payment initiation asynchronous, so no user-facing timeout can interrupt it.
UNKNOWN is a required state
public enum PaymentState {
INITIATED,
AUTHORIZED,
CAPTURED,
DECLINED, // a real answer from the issuer
FAILED, // we know it didn't happen
UNKNOWN, // we genuinely don't know - must be resolved
REFUNDED
}
DECLINED and FAILED are knowledge. UNKNOWN is the honest absence of it — and it's what lets you defer the decision instead of guessing:
try {
ChargeResult result = provider.charge(payment.storedRequest(), payment.getIdempotencyKey());
payment.setState(result.approved() ? AUTHORIZED : DECLINED);
} catch (HttpConnectTimeoutException | ConnectException nothingSent) {
// The connection was never established, so nothing was sent.
// Not ambiguous: record it, and it's safe to retry.
payment.setState(FAILED);
} catch (IOException ambiguous) {
// Sent, then lost - including a read timeout. We do NOT know.
payment.setState(UNKNOWN);
log.warn("payment {} ambiguous, key={}", payment.getId(), payment.getIdempotencyKey());
} finally {
payments.save(payment);
}
The order of those catch blocks matters: HttpConnectTimeoutException is a subclass of HttpTimeoutException, which is an IOException, so the specific case has to come first. This structure was compiled on Java 21 and checked against real sockets — a refused connection and a connect timeout land in FAILED, a server that accepts the request and never replies lands in UNKNOWN.
If you use a provider's SDK rather than a raw HttpClient, it wraps these in its own exception types, so check which ones it throws. The split is what matters: "couldn't connect" is an answer; "sent and didn't hear back" isn't.
And make the customer-facing message match what you actually know: "We're confirming your payment" — never "Payment failed, please try again," which invites the duplicate charge you're trying to avoid.
Resolving unknowns — and the mistake that fails real payments
The provider knows what happened. But how you ask matters, because the obvious way to ask can give you a confident wrong answer.
Resolve in layers:
- Webhooks. Most unknowns resolve themselves when the provider sends the success or failure event. The sweep job below is for the ones that don't.
- Replay the identical request with the same idempotency key while the provider still remembers it. You get the saved result of the original — or, if it never executed, it runs now, exactly once. Stripe keeps keys for at least 24 hours, returns the saved result even for a stored 500, and rejects a replay whose parameters differ.
- A strongly consistent lookup — by the provider's own ID if you got one, or a list call filtered by customer and time window, matched on your reference in metadata.
The trap is treating "not found" as "never happened". Some providers don't offer a lookup by idempotency key at all, and search endpoints are often eventually consistent. Stripe's docs say not to use its Search API right after a payment: new data is normally searchable within a minute, and it can take longer during an outage. A resolver that marks a payment FAILED because a search came back empty will, sooner or later, fail a payment that succeeded.
@Scheduled(fixedDelay = 30_000)
public void resolveUnknownPayments() {
Instant graceCutoff = Instant.now().minus(Duration.ofMinutes(2));
for (Payment p : payments.findByStateAndUpdatedAtBefore(UNKNOWN, graceCutoff)) {
try {
Optional<ChargeResult> answer = resolve(p);
if (answer.isPresent()) {
adopt(p, answer.get()); // definitive: approved or declined
} else if (p.isUnknownLongerThan(Duration.ofHours(1))) {
escalate(p); // a human or reconciliation takes over
}
} catch (Exception e) {
// A lookup error is not an answer. Leave it UNKNOWN.
log.error("could not resolve payment {}", p.getId(), e);
}
}
}
private Optional<ChargeResult> resolve(Payment p) {
// Replay only well inside the key's retention window, and only if the
// customer's intent still stands (order not cancelled in the meantime).
if (p.getFirstAttemptAt().isAfter(Instant.now().minus(Duration.ofHours(20)))
&& p.orderStillWantsPayment()) {
ChargeResult replay = provider.charge(p.storedRequest(), p.getIdempotencyKey());
if (replay.isDefinitive()) {
return Optional.of(replay);
}
}
// Strongly consistent lookup - never a search endpoint.
return provider.findByReference(p.getCustomerRef(), p.getReference(), p.getFirstAttemptAt());
}
Four details that matter:
-
Grace period from the last attempt (
updatedAt, notcreatedAt), so you don't resolve a payment underneath a retry that's still running. -
Nothing becomes
FAILEDbecause it wasn't found. Only a definitive answer changes the state. Everything else staysUNKNOWNand escalates. -
A lookup error stays
UNKNOWN. Never let a resolver bug downgrade a payment. - Persist the key and the exact request before the first call. A replay needs both.
Alert on age, not just errors
A few UNKNOWN payments are normal. What matters is the shape:
- Count rising — provider degradation, or your timeouts are too tight.
- Age rising — your resolver is broken. This one is invisible on error dashboards, because nothing throws: payments just pile up in limbo.
Alert on the age of the oldest unresolved payment. If anything has been UNKNOWN for more than a few minutes, someone should know.
Retrying after a timeout
Safe under one condition: the same idempotency key. With the original key, a retry completes the original operation or returns its saved result. With a new key, you've authorized a second charge.
And the key only protects you while the provider remembers it. Retry after the retention window and it's a brand-new request — which is why you also need the database guard from part 1 of this series, where a payment stuck in UNKNOWN blocks a second attempt at the database level.
Cap retries at two or three. Hammering a struggling provider is how a slowdown becomes an outage.
Checklist
- [ ] Read timeouts never write
FAILED - [ ] Connect failures handled separately from read timeouts
- [ ] An explicit
UNKNOWNstate - [ ] Read timeout above your realistic p99, connect timeout short
- [ ] Every timeout in the chain audited — gateway, proxy, load balancer, client
- [ ] Idempotency key and exact request persisted before the call
- [ ] Resolution in layers: webhooks → same-key replay → strongly consistent lookup
- [ ] "Not found" stays
UNKNOWN; no search endpoints in the resolver - [ ] An alert on the age of the oldest
UNKNOWNpayment
A timeout is a question, not an answer. Code that treats it as an answer is wrong a meaningful fraction of the time — and always in the direction that costs the customer money.
Part 2 of a series on building payment systems as a backend engineer. The full version on my site goes deeper on the resolver. I also build free, in-browser tools for payment engineers — including a card decline code lookup and a webhook signature verifier.
Top comments (0)