Skip to content

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.

Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories