supabase-js try/catch Never Fires, Errors Get Dropped
This article may contain affiliate links. Its content is not affected by advertising.
In short
supabase-js doesn't throw on network or PostgREST failure — it resolves to {data, error} — so a try/catch that only inspects data never fires, and can't tell a transient failure from an absent row.
Conclusion
supabase-js query methods never throw on network or PostgREST failure. The contract template’s current-version resolver branched only on whether data was present, without ever looking at the error that came back alongside it. That meant a transient read failure couldn’t be told apart from “no row has been published yet” — and when that return value fed a version-freeze decision, it could freeze an old version the customer never agreed to. The fix: pull the read into its own function, check error explicitly, and fail closed (return null, freeze nothing) when the read itself failed.
What this can look like
The affected code is kimiteras-portal’s contract-template resolver (contract-template-store.ts). The advertiser terms start as a JSON file shipped with the code (v1, the baseline, immutable). When an operator publishes a revision from the admin screen, a new version (v2 and up) is inserted into contract_template_versions in the database. Two functions shared the “DB if present, baseline otherwise” logic: getContractTemplate, which returns the body text, and getContractMeta, which returns metadata such as the version number and whether an auto-renewal clause applies.
Before the fix, getContractMeta looked like this:
export async function getContractMeta(
kind: string
): Promise<ContractMeta | null> {
try {
const admin = createSupabaseAdminClient();
const { data } = await admin
.from("contract_template_versions")
.select(SELECT)
.eq("kind", kind)
.eq("is_current", true)
.maybeSingle();
if (data) return metaOf(data as Row);
} catch (e) {
console.error("[contract-template] current meta read failed:", e);
}
return getContractMetaBaseline(kind);
}
Only data is destructured from maybeSingle()’s return value — error isn’t even pulled out. When the read fails, data simply comes back null; nothing is thrown. So the code has no way to tell whether data is null because no version has been published yet, or because the read itself transiently failed — either case falls through to the same return getContractMetaBaseline(kind) (the baseline v1 metadata).
In the signing flow, freezeSignedTermsSourceVersion sets signed_terms_source_version from this return value, and applyAutoRenewFromSignedTerms later decides whether to turn on auto_renew based on that version’s has_auto_renew_clause. The baseline v1 can carry an auto-renewal clause while the current version an operator published (say, v3) doesn’t. Every time the read transiently failed, v1 — not the version the customer actually read and agreed to — became the version frozen as the source of truth, opening a path to auto-renewal turning on with no contractual basis.
Cause
supabase-js query methods don’t reject their promise. Network errors and PostgREST error responses are both expressed through the same {data, error} return shape. If the caller looks only at data and ignores error, a failure is silently treated as just another case of “no data” — a normal-looking outcome. try/catch is JavaScript’s exception mechanism; it can’t catch a failure that was never thrown. The mere presence of a catch block gave a false sense that this function was handling failure.
The fix
The current-version read was pulled out into a dedicated function, readCurrentRow, that explicitly checks error and returns {row, failed}:
async function readCurrentRow(
kind: string
): Promise<{ row: Row | null; failed: boolean }> {
try {
const admin = createSupabaseAdminClient();
const { data, error } = await admin
.from("contract_template_versions")
.select(SELECT)
.eq("kind", kind)
.order("version", { ascending: false })
.limit(1)
.maybeSingle();
if (error) {
console.error("[contract-template] current read failed:", error.message);
return { row: null, failed: true };
}
return { row: (data as Row | null) ?? null, failed: false };
} catch (e) {
console.error("[contract-template] current read threw:", e);
return { row: null, failed: true };
}
}
getContractMeta now branches on that failed flag:
export async function getContractMeta(
kind: string
): Promise<ContractMeta | null> {
const { row, failed } = await readCurrentRow(kind);
if (row) return metaOf(row);
if (failed) return null;
return getContractMetaBaseline(kind);
}
When the read fails (failed === true), it returns null and does no version freeze at all — fail-closed. Since the caller, freezeSignedTermsSourceVersion, already does nothing when meta is null, this change alone closes the path where a transient failure kept freezing the wrong version. Only once “no row exists (not yet published)” and “the read failed” were distinguishable did falling back to baseline actually mean what it was supposed to mean: only when nothing had been published.
getContractTemplate, which returns the body text, still falls back to baseline on a read failure — deliberately. “The body can’t be shown” would stop the application and signing screens outright, a different trade-off that the team chose to keep, so it’s intentionally asymmetric with the fail-closed getContractMeta.
Lesson
This function already had a try/catch at commit time, complete with a console.error in the catch block — whoever wrote it believed they were handling failure. But because supabase-js expresses failure through a return value rather than an exception, the moment only data was destructured, error was silently discarded and that catch block became dead code that could never run. What made this a real defect rather than a minor oversight was that the function’s return value fed directly into a version-freeze decision tied to billing.
Frequently asked questions
Q1Why doesn't try/catch protect against this?
supabase-js query methods are designed never to reject their promise. Network outages and PostgREST errors both surface as a resolved {data: null, error} value, not a thrown exception. If the caller destructures only data and ignores error, catch never runs and the failure is swallowed.
Q2What actually misbehaved in production?
getContractMeta, which reads the current contract template version, fell back to baseline v1 metadata whenever the read failed, without checking error. The signing flow freezes signed_terms_source_version from this value, so a transient failure could freeze v1 instead of the real version (e.g. v3).
Q3What happens if v1 gets frozen instead of the real version?
v1, the baseline, carries an auto-renewal clause; v3, the actual current version, doesn't. If a contract agreed to as v3 gets frozen as v1, that version's flag turns on auto_renew — an annual charge the customer never agreed to.
Q4Did this cause an actual billing incident in production?
The source commit message only records that this was found and fixed during a review. There's no record of v1 actually being frozen and a wrong charge occurring in production.
Q5How was it fixed?
The read was extracted into readCurrentRow, which explicitly checks error and returns {row, failed}. getContractMeta now returns null (fail-closed, no freeze) when failed is true, and falls back to baseline only when the read succeeded with no row.
Environment verified
- Next.js 16.2.7 / @supabase/supabase-js ^2.106.2 / @supabase/ssr ^0.10.3
- Found and fixed the same day during review on 2026-07-24 (no record of a production billing incident)
What this article is based on
- TypeScript file lines 68-85commit 51bbdae
- TypeScript file lines 38-65commit 0a88871
- TypeScript file lines 86-98commit 0a88871
Every claim in this article comes from the records above. The repositories we operate are private so we cannot link to them, but which file, which lines, and at which commit we read them is recorded for every article. Nothing here is written from guesswork.