Skip to content

Commit 064b0dd

Browse files
author
Haider
committed
fix: bubble real dbt show error instead of generic "Could not parse"
`execDbtShow` swallowed `run()` rejections silently — when `dbt show` crashed (e.g. corrupted `dbt_packages/*`, missing `dbt_project.yml`, DB connection refused), callers saw a misleading "Could not parse dbt show output in any format" message and treated it as transient. The real `Runtime Error: ...` from `dbt`'s stderr never surfaced. Capture the `execFile` rejection's `.stderr` and `.stdout`, scan recovered JSON log lines for `level: "error"` events (dbt with `--log-format json` emits structured error events even on crash), and surface the real error. Preserve the existing generic message only when both `run()` invocations exit 0 but the output is genuinely unparseable — the condition the message was actually designed for. - src/dbt-cli.ts: 2 catch blocks now retain the error; new `extractDbtError()` helper picks structured event > stderr > message. - test/dbt-cli.test.ts: 6 new cases (real stderr surfaces, structured event preferred, ENOENT fallback, generic message preserved on exit-0 unparseable). 24/24 pass. Scoped to `execDbtShow`. `execDbtCompile` and `execDbtCompileInline` share the same masking pattern but have manifest.json / `--quiet` fallbacks that reduce impact — addressed separately if needed. Closes #932
1 parent 146acea commit 064b0dd

2 files changed

Lines changed: 128 additions & 4 deletions

File tree

packages/dbt-tools/src/dbt-cli.ts

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -222,11 +222,16 @@ export async function execDbtShow(sql: string, limit?: number) {
222222
if (limit !== undefined) args.push("--limit", String(limit))
223223

224224
let lines: Record<string, unknown>[]
225+
// Capture the run() error so we can bubble the real dbt failure up if all
226+
// parse tiers fail; the generic "Could not parse" alone misleads callers
227+
// into treating structural project errors as transient.
228+
let primaryRunError: ExecFileError | undefined
225229
try {
226230
const { stdout } = await run(args)
227231
lines = parseJsonLines(stdout)
228-
} catch {
229-
lines = []
232+
} catch (e) {
233+
primaryRunError = e as ExecFileError
234+
lines = parseJsonLines(primaryRunError.stdout ?? "")
230235
}
231236

232237
// --- Tier 1: known field paths ---
@@ -281,6 +286,7 @@ export async function execDbtShow(sql: string, limit?: number) {
281286
}
282287

283288
// --- Tier 3: plain text fallback (ASCII table) ---
289+
let plainRunError: ExecFileError | undefined
284290
try {
285291
const plainArgs = ["show", "--inline", sql]
286292
if (limit !== undefined) plainArgs.push("--limit", String(limit))
@@ -295,8 +301,15 @@ export async function execDbtShow(sql: string, limit?: number) {
295301
compiledSql: sql,
296302
}
297303
}
298-
} catch {
299-
// Plain text dbt show also failed — fall through to error below
304+
} catch (e) {
305+
plainRunError = e as ExecFileError
306+
}
307+
308+
// If either run() rejected, dbt actually crashed — surface the real error
309+
// instead of the generic "Could not parse" message.
310+
const realError = extractDbtError(lines, primaryRunError, plainRunError)
311+
if (realError) {
312+
throw new Error(`dbt show failed: ${realError}`)
300313
}
301314

302315
throw new Error(
@@ -305,6 +318,50 @@ export async function execDbtShow(sql: string, limit?: number) {
305318
)
306319
}
307320

321+
/** Shape of an execFile rejection — carries stdout/stderr alongside message. */
322+
interface ExecFileError extends Error {
323+
stdout?: string
324+
stderr?: string
325+
code?: number | string
326+
}
327+
328+
/**
329+
* Pick the best human-readable error from a failed `dbt show` invocation.
330+
*
331+
* Preference order:
332+
* 1. A structured `level: "error"` event in the JSON log (dbt's own error msg).
333+
* 2. Stderr from the JSON-mode run.
334+
* 3. Stderr from the plain-text-mode run.
335+
* 4. The exception message itself.
336+
*
337+
* Returns undefined if neither run rejected — caller falls back to the generic
338+
* "Could not parse" message, which is correct when dbt exited 0 but emitted
339+
* something we can't decode.
340+
*/
341+
function extractDbtError(
342+
lines: Record<string, unknown>[],
343+
primary?: ExecFileError,
344+
plain?: ExecFileError,
345+
): string | undefined {
346+
if (!primary && !plain) return undefined
347+
348+
const errorEvent = lines.find(
349+
(l: any) => l.info?.level === "error" || l.level === "error",
350+
) as any
351+
const structuredMsg = errorEvent?.info?.msg ?? errorEvent?.msg
352+
353+
const primaryStderr = primary?.stderr?.toString().trim()
354+
const plainStderr = plain?.stderr?.toString().trim()
355+
356+
return (
357+
(typeof structuredMsg === "string" && structuredMsg.length > 0 ? structuredMsg : undefined) ??
358+
(primaryStderr && primaryStderr.length > 0 ? primaryStderr : undefined) ??
359+
(plainStderr && plainStderr.length > 0 ? plainStderr : undefined) ??
360+
primary?.message ??
361+
plain?.message
362+
)
363+
}
364+
308365
/**
309366
* Compile a model via `dbt compile --select <model>` and return compiled SQL.
310367
*/

packages/dbt-tools/test/dbt-cli.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,73 @@ describe("execDbtShow", () => {
149149

150150
await expect(execDbtShow("SELECT 1")).rejects.toThrow("Could not parse dbt show output in any format")
151151
})
152+
153+
// --- Bubble real dbt error instead of generic "Could not parse" ---
154+
155+
test("surfaces real dbt stderr when run fails", async () => {
156+
mockExecFile.mockImplementation((_cmd: string, _args: string[], _opts: any, cb: Function) => {
157+
const err: any = new Error("Command failed: dbt show --inline ...")
158+
err.code = 1
159+
err.stdout = ""
160+
err.stderr =
161+
"Runtime Error: Failed to read package: No dbt_project.yml found at expected path dbt_packages/dbt_utils/dbt_project.yml"
162+
cb(err, err.stdout, err.stderr)
163+
})
164+
165+
await expect(execDbtShow("SELECT 1")).rejects.toThrow(/Failed to read package/)
166+
await expect(execDbtShow("SELECT 1")).rejects.toThrow(/dbt show failed/)
167+
})
168+
169+
test("prefers structured error event in JSON log over raw stderr", async () => {
170+
const errorLog = JSON.stringify({
171+
info: {
172+
level: "error",
173+
msg: "Compilation Error: Model 'foo' depends on a node named 'bar' which was not found",
174+
},
175+
})
176+
mockExecFile.mockImplementation((_cmd: string, _args: string[], _opts: any, cb: Function) => {
177+
const err: any = new Error("Command failed")
178+
err.code = 1
179+
err.stdout = errorLog
180+
err.stderr = "exit status 1"
181+
cb(err, err.stdout, err.stderr)
182+
})
183+
184+
await expect(execDbtShow("SELECT 1")).rejects.toThrow(/Compilation Error.*Model 'foo'/)
185+
})
186+
187+
test("does not surface generic 'Could not parse' when dbt actually crashed", async () => {
188+
mockExecFile.mockImplementation((_cmd: string, _args: string[], _opts: any, cb: Function) => {
189+
const err: any = new Error("Command failed")
190+
err.code = 2
191+
err.stdout = ""
192+
err.stderr = "Database Error: connection refused"
193+
cb(err, err.stdout, err.stderr)
194+
})
195+
196+
await expect(execDbtShow("SELECT 1")).rejects.not.toThrow(/Could not parse dbt show output/)
197+
})
198+
199+
test("preserves generic 'Could not parse' when dbt exited 0 but output unparseable", async () => {
200+
// Existing behavior — dbt didn't crash, we just couldn't decode its output.
201+
mockExecFile.mockImplementation((_cmd: string, _args: string[], _opts: any, cb: Function) => {
202+
cb(null, "some unparseable output", "")
203+
})
204+
205+
await expect(execDbtShow("SELECT 1")).rejects.toThrow("Could not parse dbt show output in any format")
206+
})
207+
208+
test("falls back to error message when stderr is empty", async () => {
209+
mockExecFile.mockImplementation((_cmd: string, _args: string[], _opts: any, cb: Function) => {
210+
const err: any = new Error("spawn ENOENT")
211+
err.code = "ENOENT"
212+
err.stdout = ""
213+
err.stderr = ""
214+
cb(err, "", "")
215+
})
216+
217+
await expect(execDbtShow("SELECT 1")).rejects.toThrow(/spawn ENOENT|dbt show failed/)
218+
})
152219
})
153220

154221
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)