Build1 distinct publisher3 min readUpdated
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
Compiled by The EngineerSomething wrong?How this is made
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.
Follow any of these and your For You feed starts watching them — no settings page required.
Ranked by verification strength, evidence, and original report placement.
In stripe-python v15, charge.metadata is not a dict anymore but a StripeObject; StripeObject does not subclass dict and does not implement .get().
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.
Evidence-backed comparisons of source perspectives and observed adoption signals. Read the methodology
Which Builder, Operator, and Investor concerns the observed source mix emphasized—not a truth score.
Evidence, demonstrated adoption, hype gap, incentives, and confidence are assessed independently, each on its own current evidence. How these are measured.
Specific first-person tracing, no upstream corroboration
The post supplies unusually concrete artefacts for a single-source story: the offending line, the exact exception string, the internal mechanism attributed to StripeObject.__getattr__, and the replacement helper code. But every element is one author's account of one script; no Stripe changelog, migration guide, version pin, issue link or second reproduction is cited, and the counts of affected charges are absent. That caps evidence around the midpoint.
One self-reported deployment
Supplied material evidences exactly one production pipeline — the author's own stripe-poll.py — plus the remediation deployed around it. There is no data on how many other stripe-python integrators hit the same break, no download or version-uptake figures, and no third-party report, so measured adoption is minimal even though the underlying SDK is widely used.
Slightly overstated framing, modest underlying claims
The claims themselves are narrow and mostly self-limiting: a bug, a mechanism, a fix. The mild overstatement is in generalisation — headline and dek present a broad SDK major-bump hazard while the evidence base is one script and one author's reading of StripeObject internals, with no upstream confirmation and no count of affected charges or lost revenue. That is a small positive gap, not a promotional one.
Self-published credibility incentive, no vendor stake evident
This is an author writing up their own outage on a developer blogging platform: there is an evident reputational and audience-building incentive, and a mild self-serving frame in that the story ends with the author's own fix. Offsetting that, the post is confessional about repeated self-inflicted failures (April's logging crash, an accidentally deleted scheduler entry, an unnoticed duplicate delivery path), and no sponsorship, vendor relationship or product being sold is disclosed in the supplied material.
Plausible and detailed, but single-source
Confidence is limited by structure rather than by internal quality. The account is internally consistent, technically specific and includes falsifiable details, which supports the code-level claims; but one publisher, one author, no upstream documentation and no quantified impact mean the generalisable parts — how v15 behaves for everyone, and how many integrators are exposed — cannot be confirmed from the supplied material.
build
A guard that only speaks in exit codes cannot tell you it stopped guarding1 distinct publisher
build
Your agent's retry logic is reading a timeout as a fact it does not have1 distinct publisher
build
Your script exited 0 because the file no longer existed: macOS evicted it to iCloud1 distinct publisher
build
The duplicate def that ate the trim, and the lint rule nobody was running1 distinct publisher
Distinct publishers with included, body-backed reporting in this cluster.
dev.to
1 article · August 18, 2026