Build1 publisher3 min readPublished
A Stripe SDK Major Bump Turned One Metadata Lookup Into a Silent Non-Delivery
stripe-python v15 stopped subclassing dict. A polling script that never called a deprecated API quietly stopped shipping products, and exited clean every time.
The Engineer · Build desk
Drafted by a language model from the sources cited here and checked against its claim ledger before publication. How we use AISend a correction
What happened
- The author runs a script, stripe-poll.py, that watches Stripe for successful charges, determines which digital product was purchased, and emails it out with no dashboard or human intervention.
- The script stopped noticing charges: charges came in, nothing went out, nothing logged an error, and the script exited clean every time.
- The resolution logic mapping a charge to a product ID checked the charge's metadata for a product_id using a dict-style lookup: if charge.metadata and charge.metadata.get("product_id").
- Tracing the resolution function line by line showed it was throwing AttributeError: get on the very first metadata check, before any downstream fallback logic could run, so the whole function aborted at the top.
- stripe-python had been upgraded to v15 at some point.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
A script that watches Stripe for successful charges and emails the purchased digital product stopped delivering, and reported nothing about it: charges came in, nothing went out, and the process exited clean on every run [1][2]. The cause, per a write-up published on dev.to, was that `stripe-python` had been upgraded to v15, in which `charge.metadata` is a `StripeObject` rather than a `dict`, and `StripeObject` neither subclasses `dict` nor implements `.get()` [5][6].
The offending code is the kind nobody re-reads. The resolution function that maps a charge to a product ID checked the charge metadata for a `product_id` with a dict-style lookup [3]. The author traced the function line by line and found `AttributeError: get` being raised on that very first check, aborting the whole function before any of the downstream fallback logic could run [4]. The charges themselves were normal purchases: real, succeeded, with `product_id` sitting in Stripe's metadata [7][8].
Two details make this worse than a straightforward type change. First, the diagnosis in the post is that calling `.get` on a `StripeObject` does not return `None` for a missing key; it falls through to `__getattr__`, which raises `KeyError('get')` because `get` is not a real attribute, and that surfaces up the stack as `AttributeError: get` [9]. Second, the guard in front of it was already dead weight: `StripeObject` implements neither `__bool__` nor `__len__`, so `if charge.metadata` evaluates true even when metadata is empty [10]. The truthiness check was doing nothing useful in either direction; the `.get` call is what blew up [10][11]. The code was not wrong about its intent. It was wrong about what kind of object it was holding, and the SDK changed that out from under it [12].
The failure mode is the reusable part. The exception fired upstream of delivery, so the charge was skipped with no product sent, no alert, and no trace beyond a clean exit code [13]. The same script had produced the same signature once before: in April, `logging.basicConfig(filename=...)` at module scope raised `PermissionError` when the log file was locked by another process, crashing the script before it ever reached Stripe, and 48 polling runs across 3 days exited clean and did nothing [14]. That is roughly 16 no-op runs a day, each one indistinguishable from success [15]. The thing meant to record the failure was the failure [14].
The fix was not a `try/except` around one line. According to the author it was to stop assuming Stripe objects behave like dicts anywhere in the codebase, via two helpers: `_safe_get`, which checks with `isinstance` whether it holds a dict before choosing `.get()` or `getattr()`, and `_as_id`, which normalises a Stripe reference that may arrive as a bare ID string or as a fully expanded object depending on how the API call was made [16][17][18]. Product resolution is now a deterministic chain with defensive reads at each hop: charge metadata, payment_intent metadata, checkout session, line items, price, product [19].
What to watch: whether your own integrations treat SDK return values as mappings, since a major bump can invalidate code that never touched a documented deprecation. And whether any polling job in your fleet can complete zero units of work and still exit zero without anyone hearing about it.