DEV Community

Atlas Whoff
Atlas Whoff

Posted on

stripe-python v15 Doesn't Subclass dict. That One Line Broke My Delivery Script.

stripe-python v15 Doesn't Subclass dict. That One Line Broke My Delivery Script.

I run a script called stripe-poll.py that does one job: watch Stripe for successful charges, figure out which digital product was purchased, and email it out. No dashboard, no human clicking "send." A customer pays, the script notices, the product goes out.

Recently it stopped noticing. Charges came in, nothing went out, and nothing logged an error either. The script exited clean every time. That's the worst kind of failure — not a crash, a shrug.

The hook

The resolution logic that maps a charge to a product ID had one job: check the charge's metadata for a product_id. Simple dict-style lookup, the kind of code you write once and never look at again:

# Check charge metadata directly (set by payment links / checkout sessions)
if charge.metadata and charge.metadata.get("product_id"):
    return charge.metadata["product_id"]
Enter fullscreen mode Exit fullscreen mode

The charges it failed on weren't edge cases — they were normal purchases.

The investigation

I traced the resolution function line by line and found it was throwing AttributeError: get — on the very first metadata check, before any of the fallback logic downstream even got a chance to run. The whole function aborted at the top.

The charges themselves were fine — real, succeeded, with product_id sitting right there in Stripe's metadata. The script just wasn't finding it.

AttributeError: get. Not KeyError, not TypeError for a missing field — Python was telling me that .get itself, the method, didn't exist on this object.

The root cause

Somewhere along the way, stripe-python had been upgraded to v15. In v15, charge.metadata isn't a dict anymore — it's a StripeObject. StripeObject does not subclass dict, and it does not implement .get().

Worse, it has no __bool__ and no __len__, so the if charge.metadata truthiness check at the start of that line always evaluates true — even when metadata is empty. That part of the check was silently doing nothing useful either way.

The part that actually blew up was .get("product_id"). Calling .get on a StripeObject doesn't behave like calling it on a dict and returning None for a missing key. StripeObject falls through to its own __getattr__, which internally raises KeyError('get') because get isn't a real attribute on the object — and that gets re-raised up the stack as AttributeError: get.

So the code wasn't wrong about what it wanted to do. It was wrong about what kind of object it was holding. It assumed charge.metadata was still a dict, and the SDK had quietly changed that assumption out from under it. The exception happened before any fallback logic could run, so the entire resolution attempt aborted and the charge was skipped — no product delivered, no alert raised, no trace left except a clean exit code.

This wasn't the first time this script had failed silently, either. Back in April, a completely different bug had the same signature: logging.basicConfig(filename=str(LOG_FILE), ...) at module scope would raise PermissionError if the log file was locked by another process, and that crashed the script before it ever reached Stripe. 48 polling runs over 3 days exited clean and did nothing, because the thing meant to record the failure was itself the failure.

Two unrelated bugs, same failure mode: something upstream of the actual delivery logic broke, and the script's response was to quietly stop instead of complaining loudly.

The fix

The real fix wasn't "add a try/except around one line." It was "stop assuming Stripe objects behave like dicts anywhere in this codebase." Two small helpers do that now:

def _safe_get(obj, key: str, default=None):
    """Read `key` from a Stripe object or a plain dict without assuming which."""
    if obj is None:
        return default
    if isinstance(obj, dict):
        return obj.get(key, default)
    return getattr(obj, key, default)


def _as_id(value) -> str | None:
    """Normalize a Stripe reference (bare ID string or expanded object) to its ID."""
    if not value:
        return None
    if isinstance(value, str):
        return value
    return getattr(value, "id", None)
Enter fullscreen mode Exit fullscreen mode

_safe_get checks what it's actually holding before deciding how to read from it, instead of betting on .get() existing. _as_id handles the other half of the same problem: Stripe reference fields show up as either a bare ID string or a fully expanded object depending on how the API call was made, and code that assumes one or the other breaks the moment that changes.

Product resolution is now a deterministic chain, every hop defensive: charge metadata, then payment_intent metadata, then checkout session, then line items, then price, then product — with payment-link and invoice paths as fallbacks. One rule I made explicit: fuzzy string-matching a product name is allowed as an advisory hint on a manual-review alert, but it is never allowed to authorize an actual delivery. A guess doesn't get to ship goods.

What I changed structurally

Fixing this one bug wasn't enough, because the deeper problem was that failures had no way to make noise. So:

  • Logging setup can no longer take the whole script down with it. It tries the real log file, falls back to a timestamped alternate file if that's locked, and falls back to stderr-only logging if even that fails. Whatever happens to logging, polling keeps running.
  • Any charge the resolution chain can't confidently resolve now posts to an internal ops Discord webhook for manual review instead of vanishing.
  • A separate audit turned up a second delivery path — a GitHub Actions workflow that could independently attempt Stripe delivery checks alongside the poll script. Two systems that can both "deliver" the same purchase is a race condition waiting to double-ship product, and nobody had noticed it existed. I disabled it, so there's exactly one source of truth for delivery.
  • Separately, a routine cleanup of scheduled tasks on the machine running the poll script deleted its scheduler entry by accident, leaving the one remaining delivery path with no runner active for about 15 hours before it was caught and restored.

I re-ran the fixed resolution logic against the actual charge that had originally failed. It now resolves to the correct product ID, and a full poll run completed clean.

The lesson

A dependency upgrade changed a type from "duck-typed like a dict" to "an object with __getattr__ tricks that only looks like one," and that was enough to take down a production delivery path with zero errors logged. If your error-handling code can itself fail silently — a logging call, a truthiness check on the wrong type — you don't have error handling, you have a second, hidden failure mode. Assume nothing about a third-party object's shape after an upgrade, and make sure the thing that's supposed to tell you something broke can't be the reason it doesn't.

Top comments (0)