How to debug a payment that half succeeded.

•

•

Charged but Not Recorded

I remember sitting in a dark apartment three years ago, the only light coming from a terminal window, trying to figure out why a customer had been charged but their order remained “pending.” There is no specific kind of dread quite like the one you feel when you realize you’re staring at a distributed system that has entered a state of partial failure. Most people will tell you to just add more logging or implement a fancy distributed tracing tool, but when you’re actually trying to figure out how to debug a payment that half succeeded, those tools often just give you more noise to sift through. You don’t need more data; you need to understand the exact moment the atomicity broke.

I’m not here to pitch you a new observability platform or lecture you on microservices theory. Instead, I want to walk through the messy, unglamorous reality of tracing a transaction through a broken state machine. I’ll share the specific mental model I use to isolate where the side effects diverged from the intent, and the practical steps I take to reconcile the books without losing my mind. This isn’t a guide on how to build a perfect system—because we both know that’s impossible—it’s about how to fix things when they inevitably break.

Table of Contents

Chasing Ghosts in Distributed Systems Failure Modes

Chasing Ghosts in Distributed Systems Failure Modes

The problem with distributed systems is that they don’t just fail; they fail in ways that feel personal. You expect a binary outcome—either the money moved or it didn’t—but in reality, you’re often stuck in a gray zone where the database says one thing and the bank says another. This is the essence of distributed systems failure modes: the network disappears exactly at the moment the gateway sends a 200 OK, or your worker process dies right after committing the local transaction but before updating the external provider.

I’ve learned the hard way that chasing these ghosts usually means realizing you were assuming atomicity where none actually existed. In a monolith, you have the luxury of a single database transaction to wrap everything up. In a fintech environment, you’re orchestrating a dance between your API, a message queue, and a third-party provider. When that dance breaks mid-step, you aren’t just looking for a bug; you’re trying to figure out which part of the distributed transaction atomicity was violated. You aren’t just debugging code anymore; you’re debugging the gaps between services.

Why Distributed Transactions Atomicity Failed Your Production Environment

Why Distributed Transactions Atomicity Failed Your Production Environment

We like to pretend that our services are a single, cohesive unit, but in reality, they are a collection of strangers constantly shouting at each other over a network. When I was at the insurance firm, we relied heavily on the idea of distributed transactions atomicity—the comforting belief that either everything happens or nothing happens. It’s a lovely theory. In production, however, that theory dies the moment a network partition occurs or a third-party API decides to time out halfway through a handshake. You end up in that purgatory where the money has left the user’s account, but your internal ledger hasn’t heard a word about it.

The problem usually isn’t that the code is “broken” in a traditional sense; it’s that we fail to account for the gray area between a success and a failure. Most of our logic handles the happy path or a hard crash, but it struggles with handling inconsistent payment states during a partial timeout. You send a request to the gateway, the gateway processes it, but the connection snaps before you get the 200 OK. To your system, that looks like a failure, but to the bank, it’s a completed transaction. If you haven’t built your logic to be idempotent, you aren’t just debugging a bug; you’re managing a financial discrepancy.

My survival kit for when the state doesn't match the reality

  • Stop looking at the logs and start looking at the database timestamps. When a payment “half-succeeds,” you aren’t looking for a missing error message; you’re looking for the gap between when the gateway said “yes” and when your local ledger said “maybe.” If those two timestamps are drifting, your reconciliation logic is already dead in the water.
  • Trace the idempotency key like it’s your life depends on it. I’ve seen too many “half-successes” that were actually just a client retrying a request that had already partially executed. If your idempotency layer isn’t locking the record before the external API call starts, you’re just inviting race conditions to dinner.
  • Audit your “zombie” states. If your state machine has a status called `PENDING` that stays there for more than ten minutes, you don’t have a state machine—you have a graveyard. You need a way to programmatically identify these stuck transitions so you aren’t manually querying SQL at 3 AM to see if a user actually paid.
  • Check the “silent killers”—the timeouts. A common reason for a partial success is a network timeout occurring after the third-party provider processed the money but before they sent the ACK. If your code treats a timeout as a failure instead of an “unknown,” you will end up with a customer who is charged but has no order.
  • Build a “reconciliation script” before you build the feature. It sounds backwards, but if you can’t write a script that compares your internal database against the provider’s API export to find mismatches, you have no way of knowing how much money you’re losing until the accounting department calls you.

The Lessons I Learned the Hard Way

Stop assuming “success” is a binary state; in a distributed system, “partially done” is a much more common reality that your code needs to treat as a first-class citizen.

If your recovery logic relies on the same network path that just failed, you haven’t built a safety net, you’ve just built a delay.

Idempotency isn’t a “nice to have” feature for your API; it is the only thing that prevents a retry from turning a minor hiccup into a massive financial discrepancy.

The Long View on Partial Success

The Long View on Partial Success.

At the end of the day, debugging a half-succeeded payment isn’t about finding a single missing line of code; it’s about acknowledging that distributed systems are fundamentally messy. We’ve looked at how the lack of true atomicity leaves us with these awkward, intermediate states, and how chasing ghosts in the network often leads us down the wrong path. If you want to stop these 2:00 AM calls, you have to stop assuming the happy path is the only path. You need to design for the inevitable failure of the middle step, whether that means implementing robust idempotency keys or building out a reconciliation worker that doesn’t sleep. It is better to spend the extra engineering hours now than to spend them later trying to figure out why a customer was charged but their order never shipped.

I’ve learned through enough production incidents that perfection is a myth we tell ourselves during office hours. Your system will fail, and it will likely fail in a way that looks like a contradiction. The goal isn’t to build a system that never breaks, but to build one that is observable enough to fix when it inevitably does. Don’t get discouraged when the logs don’t immediately tell the story; that’s just part of the job. Write down what you find, fix the underlying pattern rather than just the symptom, and build something that can survive its own mistakes. That is the only way to actually sleep through the night.

Frequently Asked Questions

If I can't rely on distributed transactions to be atomic, what's the actual, practical way to ensure a user isn't charged twice when the system retries a failed request?

You use idempotency keys. It’s not magic, just a unique identifier—usually a UUID generated by the client—that you store in your database alongside the transaction record. Before you even touch the payment gateway, you check if that key has already been processed. If it has, you return the existing result instead of hitting the API again. It turns a “retry” from a potential double-charge into a simple, safe lookup.

How do I distinguish between a network timeout that actually failed and one where the payment went through but the acknowledgment just never reached my service?

This is the classic “did it actually happen?” problem. You can’t trust the timeout. If you get a 504 or a socket hang-up, you have to assume the worst: the money moved, but the confirmation got lost in the ether. I stopped trying to guess and started building for idempotency. Every request needs a unique idempotency key. Instead of asking “did it fail?”, I ask the downstream provider, “did you ever see transaction X?”

At what point does a "half-succeeded" state become too expensive to fix manually, and how do I build the tooling to automate the reconciliation instead?

It becomes too expensive the moment your engineers start spending their Monday mornings manually querying databases to find missing rows. If you’re running a single script a week to patch things, you’re fine. If you’re getting paged for it, you’re failing. I stop manual intervention when the “fix” takes longer than the incident itself. You need an idempotent reconciliation worker—something that polls your source of truth and re-drives the missing state without duplicating the actual money movement.

What kind of specific idempotency keys should I be using to make sure my retry logic doesn't just turn a partial failure into a total mess?

Don’t just use a random UUID for the whole request. If your process has multiple steps—say, charging a card and then updating a ledger—you need keys that map to the specific intent of each step. I’ve seen too many “retry loops” accidentally double-charge because the top-level ID was reused for a sub-operation. Use a composite key: `request_id + operation_type`. It ensures that even if your retry logic gets aggressive, you’re only ever re-running the exact same discrete action.

Tomasz Wierzbicki

About Tomasz Wierzbicki

I am not interested in arguing about frameworks. I am interested in why the thing broke at two in the morning and what would have prevented it. Most of what I know came from being wrong in production and writing it down afterwards.