fix(stripe): stop logging Stripe's error message on the async-stripe path #44

Merged
longjacksonle merged 1 commit from fix/DUNITE-12-redact-sdk-error-logs into main 2026-08-12 04:04:28 +02:00

DUNITE-12. BUNYIP-265 established that Stripe's error envelope must not reach the logs: error.message routinely embeds a customer email, a customer or subscription id, and card last-4. The raw-REST helpers honoured that. The async-stripe path never did.

All 23 SDK call sites logged error = %e, and StripeError's Display delegates to RequestError's, which appends the message:

ERROR dunite_stripe::service: Failed to create Stripe customer error=error reported by stripe: invalid_request_error (400) with message: "No such customer: 'cus_123' (billing@example.com)"

That is the content BUNYIP-265 removed from one path, written to the same sink from the other. DUNITE-10 kept the returned error clean (StripeErrorDetails has no field for the message) but left these log lines alone, because sanitising them costs operators debugging detail and deserved its own decision.

Change

redacted_stripe_error(&stripe::StripeError) -> String renders an SDK error for a log line without the free text. Every SDK site now logs stripe_error = %redacted_stripe_error(&e).

Variant Rendered
Stripe(RequestError) through StripeErrorDetails: status, type, code, decline_code. Never message, never charge
QueryStringSerialize / JSONSerialize the serde path only - the inner message can quote the offending value, the path names the broken field
ClientError(String) verbatim: it comes from the HTTP client (connect, TLS, DNS), not from a Stripe body
Timeout / UnsupportedVersion the variant; neither carries data

The match is exhaustive with no catch-all, so a future async-stripe variant is a compile error rather than a silent message leak.

The five reqwest sites keep error = %e and gain a comment saying why: those are transport failures or resp.json() decode failures, and serde reports a position and an expected type, never the bytes it was reading. Every call site keeps its own context fields (price_id, customer_id, invoice_id, the hint strings) - ids the caller already had, and what keeps a redacted line actionable.

What this costs

The human-readable message goes away, including strings like No such price: 'price_abc'. In exchange: the code (resource_missing, card_declined, more_permissions_required) still names the fault, the call site still logs the id it passed, and since DUNITE-10 the same fields reach the caller so a consumer can render an accurate message. An operator who needs the original text looks it up in the Stripe dashboard by request id rather than keeping a copy of customer data in our log store.

Rejected alternatives: logging the message at debug (same data, same sink, on a level that gets turned on during exactly the incidents where logs are most likely to be shipped) and regex-scrubbing the message (a denylist over third-party free text fails open).

Testing

just fmt, just check, just lint, just test all clean. Three new tests (15 in the crate):

  • A RequestError whose message carries an email and a customer id renders neither, while type, code and status all survive.
  • A serde path error renders unit_amount without the value that failed to parse.
  • The non-response variants render their fixed strings, including a client error kept verbatim.

Adds serde_path_to_error as a dev-dependency to build the second case: same 0.1.x async-stripe depends on, so the StripeError variant accepts it.

Note: grep "error = %e" returns 5 hits, all reqwest. Independent of #43 (DUNITE-11), which touches URL construction and constructors; both branch from the same main and do not overlap.

DUNITE-12. BUNYIP-265 established that Stripe's error envelope must not reach the logs: `error.message` routinely embeds a customer email, a customer or subscription id, and card last-4. The raw-REST helpers honoured that. The async-stripe path never did. All 23 SDK call sites logged `error = %e`, and `StripeError`'s `Display` delegates to `RequestError`'s, which appends the message: ``` ERROR dunite_stripe::service: Failed to create Stripe customer error=error reported by stripe: invalid_request_error (400) with message: "No such customer: 'cus_123' (billing@example.com)" ``` That is the content BUNYIP-265 removed from one path, written to the same sink from the other. DUNITE-10 kept the returned *error* clean (`StripeErrorDetails` has no field for the message) but left these log lines alone, because sanitising them costs operators debugging detail and deserved its own decision. ## Change `redacted_stripe_error(&stripe::StripeError) -> String` renders an SDK error for a log line without the free text. Every SDK site now logs `stripe_error = %redacted_stripe_error(&e)`. | Variant | Rendered | |---|---| | `Stripe(RequestError)` | through `StripeErrorDetails`: status, type, code, decline_code. Never `message`, never `charge` | | `QueryStringSerialize` / `JSONSerialize` | the serde **path** only - the inner message can quote the offending value, the path names the broken field | | `ClientError(String)` | verbatim: it comes from the HTTP client (connect, TLS, DNS), not from a Stripe body | | `Timeout` / `UnsupportedVersion` | the variant; neither carries data | The match is exhaustive with no catch-all, so a future async-stripe variant is a compile error rather than a silent message leak. The five `reqwest` sites keep `error = %e` and gain a comment saying why: those are transport failures or `resp.json()` decode failures, and serde reports a position and an expected type, never the bytes it was reading. Every call site keeps its own context fields (`price_id`, `customer_id`, `invoice_id`, the `hint` strings) - ids the caller already had, and what keeps a redacted line actionable. ## What this costs The human-readable message goes away, including strings like `No such price: 'price_abc'`. In exchange: the code (`resource_missing`, `card_declined`, `more_permissions_required`) still names the fault, the call site still logs the id it passed, and since DUNITE-10 the same fields reach the caller so a consumer can render an accurate message. An operator who needs the original text looks it up in the Stripe dashboard by request id rather than keeping a copy of customer data in our log store. Rejected alternatives: logging the message at `debug` (same data, same sink, on a level that gets turned on during exactly the incidents where logs are most likely to be shipped) and regex-scrubbing the message (a denylist over third-party free text fails open). ## Testing `just fmt`, `just check`, `just lint`, `just test` all clean. Three new tests (15 in the crate): - A `RequestError` whose message carries an email and a customer id renders neither, while type, code and status all survive. - A serde path error renders `unit_amount` without the value that failed to parse. - The non-response variants render their fixed strings, including a client error kept verbatim. Adds `serde_path_to_error` as a dev-dependency to build the second case: same 0.1.x async-stripe depends on, so the `StripeError` variant accepts it. Note: `grep "error = %e"` returns 5 hits, all `reqwest`. Independent of #43 (DUNITE-11), which touches URL construction and constructors; both branch from the same main and do not overlap.
fix(stripe): stop logging Stripe's error message on the SDK path
All checks were successful
Check / fmt + clippy + test (pull_request) Successful in 1m4s
create-release / create-release (pull_request) Has been skipped
16e9321b9c
DUNITE-12. BUNYIP-265 established that Stripe's error envelope must not reach the logs: `error.message` routinely embeds a customer email, a customer or subscription id, and card last-4. The raw-REST helpers honoured that, but the async-stripe path never did. All 23 SDK call sites logged `error = %e`, and `StripeError`'s `Display` delegates to `RequestError`'s, which appends `with message: "No such customer: 'cus_123' (billing@example.com)"`. The same content BUNYIP-265 removed from one path was being written to the same sink from the other. DUNITE-10 kept the returned error clean but deliberately left these lines for their own decision.

`redacted_stripe_error` renders a `stripe::StripeError` for a log line without the free text, and every SDK site now logs `stripe_error = %redacted_stripe_error(&e)`. Per variant: a Stripe response renders through `StripeErrorDetails` (status, type, code, decline_code, never message or charge); a serde failure renders the field *path* only, since the inner serde message can quote the offending value while the path is what names the broken field; a client error renders verbatim, because it comes from the HTTP client (connect, TLS, DNS) rather than from a Stripe body; timeout and unsupported-version render their variant. The match is exhaustive with no catch-all, so a future async-stripe variant is a compile error rather than a silent message leak.

The five `reqwest` sites keep `error = %e` and gain a comment saying why: those errors are transport failures or `resp.json()` decode failures, and serde reports a position and an expected type, never the bytes it was reading. Every call site keeps its own context fields (`price_id`, `customer_id`, `invoice_id`, the `hint` strings) - those are ids the caller already had, and they are what keeps a redacted line actionable.

What is lost is the human-readable message, e.g. `No such price: 'price_abc'`. In exchange the code (`resource_missing`, `card_declined`, `more_permissions_required`) still names the fault, the call site still logs the id it passed, and since DUNITE-10 the same fields reach the caller. An operator who needs the original text looks it up in the Stripe dashboard by request id rather than keeping a copy of customer data in our log store.

Three tests: a `RequestError` whose message carries an email and a customer id renders neither while keeping type, code and status; a serde path error renders `unit_amount` without the value that failed to parse; and the non-response variants render their fixed strings.
longjacksonle scheduled this pull request to auto merge when all checks succeed 2026-08-12 04:04:14 +02:00
longjacksonle deleted branch fix/DUNITE-12-redact-sdk-error-logs 2026-08-12 04:04:28 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/dunite!44
No description provided.