Skip to content

fix: bubble real dbt show error instead of generic "Could not parse"#933

Open
sahrizvi wants to merge 2 commits into
mainfrom
fix/bubble-dbt-show-error-932
Open

fix: bubble real dbt show error instead of generic "Could not parse"#933
sahrizvi wants to merge 2 commits into
mainfrom
fix/bubble-dbt-show-error-932

Conversation

@sahrizvi

@sahrizvi sahrizvi commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

PINEAPPLE

Closes #932

(Supersedes #931 — same commits, branch renamed to drop the stale Jira key.)

Summary

altimate-dbt execute --query "..." (which calls execDbtShow in packages/dbt-tools/src/dbt-cli.ts) swallows the real error from dbt show and surfaces a misleading generic message:

{
  "error": "Could not parse dbt show output in any format (JSON, heuristic, or plain text). Got 0 JSON lines.",
  "fix": "Run: altimate-dbt doctor"
}

But running dbt show directly produces a clear actionable error, e.g.:

Runtime Error: Failed to read package: No dbt_project.yml found at expected path dbt_packages/dbt_utils/dbt_project.yml

Agents read "could not parse" as transient and retry alternate commands instead of bailing out — burns budget on a project that is structurally broken.

Root cause

packages/dbt-tools/src/dbt-cli.ts has two adjacent catch blocks in execDbtShow that swallow the run() rejection silently:

// ~line 224 — Tier 1 JSON attempt
try { const { stdout } = await run(args); lines = parseJsonLines(stdout) }
catch { lines = [] }            // ← discards err.stderr

// ~line 298 — Tier 3 plain-text fallback
try { ... await run(plainArgs) ... }
catch { /* fall through */ }    // ← also discards err.stderr

throw new Error("Could not parse dbt show output in any format (JSON, heuristic, or plain text). ...")

When execFile rejects with a non-zero exit, the error object carries .stderr and .stdout. Both catches discard them. The generic "Could not parse" message was designed for the case "dbt exited 0 but the output is unparseable" — but it currently fires for the "dbt actually crashed" case as well, conflating two very different failure modes.

Fix

  • Capture the execFile rejection on both tiers (primaryRunError, plainRunError).
  • Recover any partial JSON log lines from the failed run's stdout — dbt with --log-format json emits structured level: "error" events before exit.
  • Before the generic throw, call extractDbtError(...) which picks (in order): structured error event > primary stderr > plain stderr > exception message.
  • If extractDbtError returns anything, surface it as dbt show failed: <real msg>. Fall through to the generic message only when both runs exited 0.

Net: ~45 lines in dbt-cli.ts (helper + 2 small guard sites), 6 new test cases.

Scope

Limited to execDbtShow. execDbtCompile and execDbtCompileInline share the same catch { lines = [] } pattern but have additional fallback paths (manifest.json for compile, no-args plain-text retry for inline) that reduce caller impact. Worth addressing in a follow-up PR if telemetry shows masking there too — kept out of this PR to keep the diff focused.

Test plan

  • bun test test/dbt-cli.test.ts → 24/24 pass (18 pre-existing + 6 new)
  • Generic "Could not parse" message still surfaces when dbt show exits 0 but emits unparseable output (regression guard preserved)
  • Real stderr from a crashing dbt show surfaces in the thrown error
  • Structured level: "error" event preferred over raw stderr when both present
  • spawn ENOENT (no dbt binary) surfaces clearly
  • Reviewer: smoke against a real project with a corrupted dbt_packages/<pkg>/dbt_project.yml; expect to see the real "Failed to read package" message, not "Could not parse"

Links


Summary by cubic

Surface the real dbt error from execDbtShow instead of the generic “Could not parse…” message, so callers get actionable failures and agents stop wasting retries. Addresses #932.

  • Bug Fixes
    • Capture run() errors for both JSON and plain-text dbt show; parse JSON lines from failed stdout to recover structured error events.
    • Add extractDbtError to prefer structured JSON error > primary stderr > plain stderr > exception message; throw as dbt show failed: <message>.
    • Preserve the generic “Could not parse…” only when dbt exits 0 with unparseable output; change is limited to execDbtShow.
    • Tests: remove a duplicate generic-error case; keep coverage for stderr surfacing, structured-event preference, and ENOENT fallback; 23 passing tests.

Written for commit 2ea03d7. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • Bug Fixes

    • Improved error handling for dbt show: surfaces actual dbt errors instead of generic "Could not parse" messages, prefers structured JSON error events, and includes stderr or execution failure details in reported messages.
  • Tests

    • Added tests covering dbt show failure scenarios, error selection priority, and fallback behaviors when stderr or structured errors are absent.

@coderabbitai

coderabbitai Bot commented Jun 11, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 170dd974-36c5-4cec-a1d2-24c3d1839392

📥 Commits

Reviewing files that changed from the base of the PR and between 064b0dd and 2ea03d7.

📒 Files selected for processing (1)
  • packages/dbt-tools/test/dbt-cli.test.ts
💤 Files with no reviewable changes (1)
  • packages/dbt-tools/test/dbt-cli.test.ts

📝 Walkthrough

Walkthrough

execDbtShow now records JSON-mode and plain-text run rejections, attempts to parse recovered stdout for JSON error events, and uses extractDbtError to surface a prioritized, human-readable dbt error instead of always throwing a generic parse-failure.

Changes

Real dbt error surfacing in execDbtShow

Layer / File(s) Summary
JSON-mode error capture and parsing
packages/dbt-tools/src/dbt-cli.ts
When the JSON-mode run() rejects, primaryRunError is captured and the rejection's stdout is parsed for JSON log lines, preserving potential error events from the captured output.
Plain-text fallback error capture and selection
packages/dbt-tools/src/dbt-cli.ts
Plain-text fallback execution is wrapped in try/catch to record plainRunError; after all parsing tiers fail, extractDbtError is called to select and throw the best available error message instead of unconditionally throwing a generic parse failure.
Error extraction prioritization helper
packages/dbt-tools/src/dbt-cli.ts
extractDbtError helper selects an error message by priority: structured JSON "level: error" events from parsed lines, stderr from JSON-mode rejection, stderr from plain-text rejection, or fallback exception messages; paired with local ExecFileError interface for execFile rejection shape.
Error handling test cases
packages/dbt-tools/test/dbt-cli.test.ts
Four new test cases verify that real dbt stderr is surfaced, JSON error events take precedence, generic parse errors are suppressed on actual command failure, and fallback behavior on empty stderr.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 I hopped through logs both terse and long,
Found the stderr hiding the honest song.
Now errors speak true instead of haze,
No more lost clues in parsing maze.
A carrot for clarity, and brighter days! 🥕✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: fixing execDbtShow to surface real dbt errors instead of a generic parse failure message.
Description check ✅ Passed The description includes the required PINEAPPLE marker, comprehensive Summary section, detailed Test Plan section, and completed Checklist; all template requirements are met.
Linked Issues check ✅ Passed The PR fully implements the requirements from #932: captures execFile rejections, recovers JSON log lines from failed stdout, adds extractDbtError to prefer structured errors over stderr, and surfaces real dbt messages instead of generic 'Could not parse' [#932].
Out of Scope Changes check ✅ Passed All changes are scoped to execDbtShow as intended; no modifications to execDbtCompile or execDbtCompileInline; changes consist only of error handling logic and test coverage directly related to the linked issue.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/bubble-dbt-show-error-932

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions

Copy link
Copy Markdown

👋 This PR was automatically closed by our quality checks.

Common reasons:

  • New GitHub account with limited contribution history
  • PR description doesn't meet our guidelines
  • Contribution appears to be AI-generated without meaningful review

If you believe this was a mistake, please open an issue explaining your intended contribution and a maintainer will help you.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 2 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/dbt-tools/src/dbt-cli.ts
Comment thread packages/dbt-tools/test/dbt-cli.test.ts Outdated
`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
@sahrizvi sahrizvi force-pushed the fix/bubble-dbt-show-error-932 branch from 62e21bf to 064b0dd Compare June 11, 2026 20:46
@github-actions

Copy link
Copy Markdown

👋 This PR was automatically closed by our quality checks.

Common reasons:

  • New GitHub account with limited contribution history
  • PR description doesn't meet our guidelines
  • Contribution appears to be AI-generated without meaningful review

If you believe this was a mistake, please open an issue explaining your intended contribution and a maintainer will help you.

2 similar comments
@github-actions

Copy link
Copy Markdown

👋 This PR was automatically closed by our quality checks.

Common reasons:

  • New GitHub account with limited contribution history
  • PR description doesn't meet our guidelines
  • Contribution appears to be AI-generated without meaningful review

If you believe this was a mistake, please open an issue explaining your intended contribution and a maintainer will help you.

@github-actions

Copy link
Copy Markdown

👋 This PR was automatically closed by our quality checks.

Common reasons:

  • New GitHub account with limited contribution history
  • PR description doesn't meet our guidelines
  • Contribution appears to be AI-generated without meaningful review

If you believe this was a mistake, please open an issue explaining your intended contribution and a maintainer will help you.

@dev-punia-altimate

Copy link
Copy Markdown
Contributor

❌ Tests — Failures Detected

TypeScript — 15 failure(s)

  • connection_refused [1.00ms]
  • timeout
  • permission_denied
  • parse_error
  • network_error
  • auth_failure [1.00ms]
  • rate_limit
  • internal_error
  • empty_error
  • connection_refused
  • timeout
  • permission_denied
  • parse_error
  • network_error
  • auth_failure [1.00ms]

Next Step

Please address the failing cases above and re-run verification.

cc @sahrizvi

The new test at dbt-cli.test.ts L199 ("preserves generic 'Could not
parse' when dbt exited 0 but output unparseable") used the same mock
implementation and asserted the same rejection string as the existing
"Tier 3: throws with helpful message when all tiers fail" test at
L145. The generic-error path is fully covered by Tier 3; any future
change to that behaviour would have had to be kept in sync across
two identical tests.

Addresses cubic-dev-ai review feedback on #933 (P3).

Tests: 23 pass / 0 fail in packages/dbt-tools/test/dbt-cli.test.ts.

Refs #932

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

👋 This PR was automatically closed by our quality checks.

Common reasons:

  • New GitHub account with limited contribution history
  • PR description doesn't meet our guidelines
  • Contribution appears to be AI-generated without meaningful review

If you believe this was a mistake, please open an issue explaining your intended contribution and a maintainer will help you.

1 similar comment
@github-actions

Copy link
Copy Markdown

👋 This PR was automatically closed by our quality checks.

Common reasons:

  • New GitHub account with limited contribution history
  • PR description doesn't meet our guidelines
  • Contribution appears to be AI-generated without meaningful review

If you believe this was a mistake, please open an issue explaining your intended contribution and a maintainer will help you.

@sahrizvi

Copy link
Copy Markdown
Contributor Author

Addresses cubic review feedback

Pushed 2ea03d79b.

cubic findings — both resolved

  • P2 (execFile rejection stdout/stderr not surfaced): already resolved upstream in 064b0dd6bexecDbtShow now captures e.stdout / e.stderr from the rejection error and feeds them into extractDbtError. Verified in current packages/dbt-tools/src/dbt-cli.ts.
  • P3 (duplicate test): the new test at dbt-cli.test.ts:199 ("preserves generic 'Could not parse' when dbt exited 0 but output unparseable") used the same mock and assertion as the existing Tier 3: throws with helpful message when all tiers fail test at line 145. Deleted the dup; Tier 3 still covers the generic-error path.

Tests + verification

  • bun test test/dbt-cli.test.ts23 pass / 0 fail
  • Independent codex review: "The commit only removes a duplicate test while preserving equivalent coverage in the existing Tier 3 failure test. No functional code or unique test coverage appears to be lost."

Centralized test bot failures — likely shared infra, not PR-caused

The dev-punia-altimate bot's 15 TypeScript failures (connection_refused / timeout / parse_error / network_error / auth_failure / rate_limit / internal_error / empty_error / oom / permission_denied) appear with the identical name set on completely unrelated PRs #935 and #937. Strong signal these are shared fault-injection-harness issues, not introduced by this PR. Happy to dig further if the bot owner wants — flagging here in case it's expected noise.

@github-actions

Copy link
Copy Markdown

👋 This PR was automatically closed by our quality checks.

Common reasons:

  • New GitHub account with limited contribution history
  • PR description doesn't meet our guidelines
  • Contribution appears to be AI-generated without meaningful review

If you believe this was a mistake, please open an issue explaining your intended contribution and a maintainer will help you.

1 similar comment
@github-actions

Copy link
Copy Markdown

👋 This PR was automatically closed by our quality checks.

Common reasons:

  • New GitHub account with limited contribution history
  • PR description doesn't meet our guidelines
  • Contribution appears to be AI-generated without meaningful review

If you believe this was a mistake, please open an issue explaining your intended contribution and a maintainer will help you.

@suryaiyer95 suryaiyer95 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Multi-model consensus review (Claude + Gemini 3.1 Pro + Kimi K2.5 + Grok 4, 1 convergence round).

Verdict: REQUEST CHANGES — Critical: 1, Major: 1, Minor: 5, Nits: 2.

The intent is excellent and the surrounding structure is clean, but as written the fix is inert in production (CRITICAL 1) and the same change opens a silent-wrong-data path once that's fixed (MAJOR 2). These two are coupled and must land together, with tests that exercise the real execFile callback wiring rather than a hand-decorated mock.

Inline comments below. (Posted as a COMMENT review, not a formal block — flip to Request Changes if you prefer.)

Note: parseJsonLines cannot throw (it wraps every JSON.parse in its own try/catch), so the "fallback breaks if parse throws" concern was considered and rejected.

lines = []
} catch (e) {
primaryRunError = e as ExecFileError
lines = parseJsonLines(primaryRunError.stdout ?? "")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CRITICAL — the fix is inert in production: primaryRunError.stdout is always undefined.

run() (line 61) rejects with the bare execFile error: if (err) reject(err). Node's execFile passes stdout/stderr as separate callback arguments, not as properties on the error object — and this manual Promise wrapper discards them on rejection (unlike util.promisify(execFile), which decorates the error). So at runtime primaryRunError.stdout and .stderr are undefined:

  • parseJsonLines(undefined ?? "")[] → no structured error event
  • primary?.stderrundefined → no stderr surfaced
  • extractDbtError falls through to primary?.message = Node's generic "Command failed: <dbt> show --inline ..."

The real dbt error this PR exists to surface (e.g. "Runtime Error: Failed to read package...") is never shown. Verified empirically by reproducing the exact run() pattern under Node v20.20 and Bun 1.3.14: 'stdout' in err === false, err.stdout === undefined in both.

Fix — attach stdout/stderr in run() before rejecting:

execFile(dbt.path, args, { ... }, (err, stdout, stderr) => {
  if (err) {
    const execErr = err as ExecFileError
    execErr.stdout = stdout
    execErr.stderr = stderr
    reject(execErr)
  } else resolve({ stdout, stderr })
})

} catch {
lines = []
} catch (e) {
primaryRunError = e as ExecFileError

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MINOR — unguarded e as ExecFileError cast (also at line 305). Low-risk since execFile realistically rejects with an Error, but a cheap guard documents intent and hardens against a non-Error rejection:

const isExecFileError = (e: unknown): e is ExecFileError => e instanceof Error
primaryRunError = isExecFileError(e) ? e : (new Error(String(e)) as ExecFileError)

Comment on lines +308 to +310
// If either run() rejected, dbt actually crashed — surface the real error
// instead of the generic "Could not parse" message.
const realError = extractDbtError(lines, primaryRunError, plainRunError)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MAJOR — feeding the failed run's stdout into Tier 1/Tier 2 can return bogus rows / mask the error.

Once CRITICAL 1 is fixed and primaryRunError.stdout is actually populated on a crash, line 234 pushes crash log lines into lines, which then flow through Tier 1 (238-242) and the Tier 2 heuristic scan (278-286) before extractDbtError is consulted here at line 310. looksLikeRowData matches any depth-≤5 array whose first element is an object, so an incidental array in a dbt crash log can be returned as spurious "rows" (silent wrong data) — worse than the original misleading error. The existing comment at lines 269-271 already warns about exactly this Tier-2 false-positive hazard.

Fix — when a run errored, extract the error before the heuristic tiers and source success-path lines only from a successful run:

if (primaryRunError || plainRunError) {
  const realError = extractDbtError(parseJsonLines(primaryRunError?.stdout ?? ""), primaryRunError, plainRunError)
  if (realError) throw new Error(`dbt show failed: ${realError}`)
}

Also add a regression test: a failed run whose stdout contains an incidental array-of-objects must throw, not return rows.

// If either run() rejected, dbt actually crashed — surface the real error
// instead of the generic "Could not parse" message.
const realError = extractDbtError(lines, primaryRunError, plainRunError)
if (realError) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MINOR — extractDbtError is invoked unconditionally even when neither run failed. It self-short-circuits via if (!primary && !plain) return undefined (line 346), so this is cosmetic, but gating the call behind if (primaryRunError || plainRunError) (the MAJOR-2 fix above) makes the intent explicit: we only look for a "real dbt error" when dbt actually errored.

Comment on lines +323 to +324
stdout?: string
stderr?: string

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIT — stdout/stderr typed string but Node delivers string | Buffer. extractDbtError already calls .toString() so runtime is fine, but the interface is a minor type lie. Widen to stdout?: string | Buffer / stderr?: string | Buffer.


const errorEvent = lines.find(
(l: any) => l.info?.level === "error" || l.level === "error",
) as any

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIT — as any undercuts the Record<string, unknown>[] typing (consensus across all three external reviewers). Prefer a small typed shape or predicate:

interface DbtLogLine { info?: { level?: string; msg?: string }; level?: string; msg?: string }
const errorEvent = lines.find((l): l is DbtLogLine =>
  (l as DbtLogLine).info?.level === "error" || (l as DbtLogLine).level === "error")

Comment on lines +156 to +162
mockExecFile.mockImplementation((_cmd: string, _args: string[], _opts: any, cb: Function) => {
const err: any = new Error("Command failed: dbt show --inline ...")
err.code = 1
err.stdout = ""
err.stderr =
"Runtime Error: Failed to read package: No dbt_project.yml found at expected path dbt_packages/dbt_utils/dbt_project.yml"
cb(err, err.stdout, err.stderr)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This mock is why the tests pass while production fails (see CRITICAL 1). It attaches stdout/stderr onto the error object (err.stdout = ...), then passes them to cb. Node/Bun never put stdout/stderr on the rejected error — they only arrive as the 2nd/3rd callback args, which run() discards. A faithful mock should leave them only on the callback args:

const err: any = new Error("Command failed")
err.code = 1
cb(err, "", "Runtime Error: Failed to read package...")  // NOT err.stdout/err.stderr

With that mock, this test would currently fail — which is the point: it would catch CRITICAL 1. Add this faithful-wiring test alongside the run() fix.

cb(err, err.stdout, err.stderr)
})

await expect(execDbtShow("SELECT 1")).rejects.toThrow(/Compilation Error.*Model 'foo'/)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MINOR — missing coverage for the top-level { level: "error" } shape. extractDbtError handles both l.info?.level and top-level l.level === "error" (line 349), but this test only emits the nested info form. Add a sibling case with { level: "error", msg: "..." } so the || l.level === "error" branch is exercised.

cb(err, err.stdout, err.stderr)
})

await expect(execDbtShow("SELECT 1")).rejects.not.toThrow(/Could not parse dbt show output/)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MINOR — negation-only assertion is weak. .rejects.not.toThrow(/Could not parse/) passes even if the function threw something unrelated (or, post-CRITICAL-1, the wrong generic message). Add a positive assertion of the intended behavior:

await expect(execDbtShow("SELECT 1")).rejects.toThrow(/Database Error.*connection refused|dbt show failed/)

})

await expect(execDbtShow("SELECT 1")).rejects.toThrow(/spawn ENOENT|dbt show failed/)
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MINOR — missing test: JSON run crashes but Tier 3 plain-text retry succeeds. Assert it returns the parsed table and does not throw — this proves the real error is surfaced only when both tiers fail, and locks in the recovery path.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

altimate-dbt execute masks real dbt show error with generic "Could not parse" message

3 participants