Skip to content

fix(max): keep the real cause when an async query fails - #89626

Draft
posthog[bot] wants to merge 4 commits into
masterfrom
posthog-self-driving/fixmax-keep-the-real-cause-when-an-c48a7e
Draft

fix(max): keep the real cause when an async query fails#89626
posthog[bot] wants to merge 4 commits into
masterfrom
posthog-self-driving/fixmax-keep-the-real-cause-when-an-c48a7e

Conversation

@posthog

@posthog posthog Bot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Problem

  • Max AI and AI subscription reports show users an opaque "Query failed" instead of the real reason a query died, and the step never retries.
  • A non-user-safe async query failure (a ClickHouse memory limit, an internal error, a worker crash) hides its message text and, until now, set no error code.
  • ee/hogai/context/insight/query_executor.py then raised a plain Exception("Query failed") with no cause. That type is absent from _RETRYABLE_QUERY_ERRORS, so the report pipeline gave up on the step instead of retrying it.
  • Every one of these failures is undiagnosable: the underlying ClickHouse error is discarded, so nobody can tell which failure is behind any of them.

Changes

  • A failed report step now retries and names its cause (for example Query failed: CHQueryErrorMemoryLimitExceeded) instead of dead-ending on "Query failed".
  • execute_process_query carries the exception class as a machine-readable error_code on the failed status, even when the message text stays hidden because it is not user-safe. It reuses the existing clickhouse_error_type helper, which is already the observability-safe representation.
  • The AI query executor reads that error_code and raises MaxToolRetryableError for the no-message case, so transient ClickHouse failures come back as retryable. This mirrors the existing structurally-valid error branch a few lines below.
  • MaxToolRetryableError joins the executor's first except clause so the catch-all below does not re-wrap it back into a plain Exception.

How did you test this code?

  • Added test_async_query_polling_error_without_message_is_retryable: a failed status with error_code but no error_message must raise MaxToolRetryableError and surface the code. Guards the retry-and-diagnose behavior this PR adds.
  • Added test_async_query_non_user_safe_error_carries_error_code_without_message: a non-user-safe error keeps error_message hidden but sets error_code to the class name. Guards the machine-readable cause.
  • Ran both new tests plus the existing error-path tests in test_query_executor.py and test_execute_async.py: all pass locally.
  • Not run: the full backend suite (CI covers it).

Automatic notifications

  • Publish to changelog?

Docs update

None — no user-facing API, setting, or documented workflow changes.

🤖 Agent context

Autonomy: Fully autonomous

  • Tooling: Claude Code (PostHog Desktop), model Opus 4.8.
  • Skills invoked: /writing-tests, /writing-pr-descriptions.
  • The fix carries the exception class rather than the raw message, so no unsafe query text or data leaks while the cause still becomes diagnosable. All no-message failures are treated as retryable because a hidden-message status is by definition a non-user-safe server-side error, matching the existing precedent that raises MaxToolRetryableError for the structurally-valid error case.
  • hogli review (Greptile) could not run in this sandbox — the CLI is not installed and flox is unavailable. A manual self-review of the diff was done instead.

Created with PostHog Desktop from this inbox report.

A non-user-safe async query failure (a ClickHouse memory limit, an internal
error, a worker crash) hid its message text and set no error code, so the AI
query executor raised an opaque "Query failed". That plain exception was excluded
from the AI report pipeline's retryable set, so the step dead-ended with no
retry and no diagnosable cause.

Carry the exception class as a machine-readable error_code on the failed status,
even when the message text stays hidden. Classify the resulting exception as
MaxToolRetryableError so transient ClickHouse failures retry and surface the
cause instead of collapsing to "Query failed".

Generated-By: PostHog Desktop
Task-Id: 5b2bf3ce-ead7-4283-8180-6fdff34dc0f4
@trunk-io

trunk-io Bot commented Aug 26, 2026

Copy link
Copy Markdown

Merging to master in this repository is managed by Trunk.

  • To merge this pull request, check the box to the left or comment /trunk merge below.

After your PR is submitted to the merge queue, this comment will be automatically updated with its status. If the PR fails, failure details will also be posted here

@posthog

posthog Bot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

🦔 ReviewHog reviewed this pull request

Found 0 must fix, 3 should fix, 1 consider.

Published 4 findings (view the review).

Resolved comments: 2 fixed, 2 left for you

@github-actions

github-actions Bot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

🤖 CI report

⚠️ Trunk lane — backend Python lane

This PR is assigned to the backend Python lane. It runs backend Python tests and may merge in parallel with PRs in other lanes.

⚠️ Bundle size — 🔺 +6.3 KiB (+0.0%)

Uncompressed size of every built .js bundle, compared against the base branch.

Total: 68.40 MiB · 🔺 +6.3 KiB (+0.0%)

File Size Δ vs base
render-query/src/render-query/render-query.js 21.69 MiB 🔺 +3.9 KiB (+0.0%)
posthog-app/src/scenes/experiments/Experiment.js 257.2 KiB 🟢 -1.7 KiB (-0.7%)

Posted automatically by build-bundle-size-report · uncompressed bytes from dist-report

Eager graph — within budget

How much code each root ships on the eager path — downloaded and parsed before the surface is interactive. Measured from the esbuild output chunks (post-tree-shake, static imports only); lazy import() / React.lazy chunks are not counted.

Root Eager (shipped) Δ vs base Budget
entry (logged-out pages, app bootstrap)
src/index.tsx
1.36 MiB · 22 files 🔺 +596 B (+0.0%) ███░░░░░░░ 30.3% of 4.51 MiB
authenticated shell (every logged-in page)
src/scenes/AuthenticatedShell.tsx
8.78 MiB · 3,237 files 🔺 +1.6 KiB (+0.0%) █████████░ 90.4% of 9.71 MiB

🟢 node_modules/monaco-editor/ stays out of src/index.tsx
🟢 src/lib/components/ActivityLog/describers stays out of src/index.tsx
🟢 [object Object] stays out of src/index.tsx
🟢 [object Object] stays out of src/index.tsx
🟢 node_modules/monaco-editor/ stays out of src/scenes/AuthenticatedShell.tsx
🟢 src/lib/components/ActivityLog/describers stays out of src/scenes/AuthenticatedShell.tsx
🟢 [object Object] stays out of src/scenes/AuthenticatedShell.tsx
🟢 [object Object] stays out of src/scenes/AuthenticatedShell.tsx

Largest files eagerly shipped from src/index.tsx
Size File
126.8 KiB ../node_modules/.pnpm/react-dom@18.3.1_react@18.3.1/node_modules/react-dom/cjs/react-dom.production.min.js
24.6 KiB ../node_modules/.pnpm/buffer@6.0.3/node_modules/buffer/index.js
6.3 KiB ../node_modules/.pnpm/react@18.3.1/node_modules/react/cjs/react.production.min.js
4.5 KiB ../node_modules/.pnpm/@jspm+core@2.1.0/node_modules/@jspm/core/nodelibs/browser/process.js
3.9 KiB ../node_modules/.pnpm/scheduler@0.23.2/node_modules/scheduler/cjs/scheduler.production.min.js
1.4 KiB ../node_modules/.pnpm/base64-js@1.5.1/node_modules/base64-js/index.js
1.3 KiB src/RootErrorBoundary.tsx
912 B ../node_modules/.pnpm/ieee754@1.2.1/node_modules/ieee754/index.js
789 B src/scenes/ChunkLoadErrorBoundary.tsx
762 B src/index.tsx
Largest files eagerly shipped from src/scenes/AuthenticatedShell.tsx
Size File
306.5 KiB ../node_modules/.pnpm/posthog-js@1.418.17/node_modules/posthog-js/dist/rrweb.js
267.7 KiB ../node_modules/.pnpm/@posthog+icons@0.38.0_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/@posthog/icons/dist/posthog-icons.es.js
258.5 KiB ../node_modules/.pnpm/posthog-js@1.418.17/node_modules/posthog-js/dist/module.js
246.0 KiB src/taxonomy/core-filter-definitions-by-group.json
154.2 KiB ../node_modules/.pnpm/re2js@0.4.1/node_modules/re2js/build/index.esm.js
126.8 KiB ../node_modules/.pnpm/react-dom@18.3.1_react@18.3.1/node_modules/react-dom/cjs/react-dom.production.min.js
104.7 KiB src/lib/api.ts
95.2 KiB ../packages/quill/packages/quill/dist/index.js
93.3 KiB ../node_modules/.pnpm/prosemirror-view@1.40.1/node_modules/prosemirror-view/dist/index.js
90.6 KiB ../node_modules/.pnpm/@tiptap+core@3.20.6_@tiptap+pm@3.20.6/node_modules/@tiptap/core/dist/index.js

Posted automatically by check-eager-graph · sizes are eager output bytes (shipped, post-tree-shake) from the esbuild metafile · part of #32479

Toolbar bundle — eager 2.25 MiB within budget

What the toolbar ships to customer pages, measured from the esbuild output (minified, post-tree-shake). The eager set is the entry plus everything statically imported from it — fetched before any feature runs; deferred chunks load lazily. The eager guardrail is 5.72 MiB. Each output file must also stay below 10 MB, where CloudFront stops compressing it. The module boundary is enforced separately by check-toolbar-graph.

Metric Size Δ vs base Budget
Eager (shipped)
entry + static imports
2.25 MiB · 17 files 🔺 +498 B (+0.0%) ████░░░░░░ 39.3% of 5.72 MiB
Deferred (lazy) 2.09 MiB · 33 files no change n/a — loads on demand
Loader dist/toolbar.js 1.1 KiB no change █░░░░░░░░░ 5.8% of 19.5 KiB
Largest eagerly-shipped chunks
Size File
746.7 KiB dist/toolbar/toolbar-app-RBPKVAJT.css
582.8 KiB dist/toolbar/chunk-chunk-BYSB3M44.js
484.6 KiB dist/toolbar/chunk-chunk-QOQLAFDJ.js
133.8 KiB dist/toolbar/chunk-chunk-74HT5EN3.js
131.8 KiB dist/toolbar/chunk-chunk-T5KY5WYR.js
71.3 KiB dist/toolbar/toolbar-app-Y3G6XWUE.js
69.0 KiB dist/toolbar/chunk-chunk-27JL52RE.js
35.6 KiB dist/toolbar/chunk-chunk-JZL2JUHN.js
20.9 KiB dist/toolbar/chunk-chunk-3BBZHAYC.js
12.2 KiB dist/toolbar/chunk-chunk-PIK3PADE.js

Posted automatically by check-toolbar-size · sizes are toolbar output bytes (shipped, post-tree-shake) from the esbuild metafile

Dist folder size — 🔺 +264.1 KiB (+0.0%)

Total size of the built frontend/dist folder (all assets), compared against the base branch.

Total: 1442.27 MiB · 🔺 +264.1 KiB (+0.0%)

stamphog[bot]
stamphog Bot previously approved these changes Aug 26, 2026

@stamphog stamphog 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.

Small, well-tested fix that makes hidden-message async query failures retryable and diagnosable via a machine-readable error code, without exposing sensitive message text; not risky territory and no unresolved concerns.

Gate mechanics and policy version
Gate Result
prerequisites all clear
deny-list no deny categories matched
size 15L, 2F substantive, 58L/4F incl. docs/generated/snapshots — within ceiling
tier T1-agent / T1b-small (58L, 4F, two-areas, fix)
stamphog 2.0.0b4 .stamphog/policy.yml @ b294ef8 · reviewed head b294ef8

@posthog

posthog Bot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

ReviewHog Alpha 🦔 If you find any issues helpful - please reply "valid", "invalid", etc., for evaluation purposes 🙏

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

ReviewHog Report

Found 3 should fix, 1 consider.

Comment on lines +445 to +450
# The status hides the message text because it's not user-safe, but error_code
# carries the machine-readable cause (a ClickHouse memory limit, an internal
# error, a worker crash). These are transient, so retry instead of dead-ending
# on an opaque "Query failed" that the report pipeline never retries.
error_code = query_status.get("error_code")
raise MaxToolRetryableError(f"Query failed: {error_code}" if error_code else "Query failed")

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Structured cause is lost before report classification

should_fix bug

Why we think it's a valid issue
  • Checked: the failure path from ee/hogai/context/insight/query_executor.py:449-450 into the AI report pipeline (products/exports/backend/temporal/subscriptions/ai_subscription/report_pipeline.py), the disclosure helpers in products/exports/backend/temporal/subscriptions/types.py, and the delivery UI in products/subscriptions/frontend/scenes/components/SubscriptionAiReportDelivery.tsx.
  • Found: the new error_code lives only inside the exception text. report_pipeline.py:589 sets type_name = type(last_exc).__name__, which is MaxToolRetryableError. report_pipeline.py:590 calls undisclosed_query_error_type(last_exc), which walks __cause__/__context__ for a ClickHouse class; the executor raises from a status dict, so that walk finds nothing. safe_error_message() (types.py:47) matches only ExposedHogQLError/ResolutionError, so human_readable_error stays None. The persisted QueryStepDiagnostic.error_type therefore records MaxToolRetryableError.
  • Found: the same class name reaches copy a person reads. The step placeholder at report_pipeline.py:600-602 renders Query failed to run (MaxToolRetryableError), and GenerateAIReportResult.failure_error() (types.py:288-298) builds the fully-degraded delivery message from those type names.
  • Found: a second effect of the new retry path. report_pipeline.py:575 feeds the fix LLM safe_error_message(exc) or type(exc).__name__, so each retry asks for a rewrite based on the string MaxToolRetryableError. Each failed step now spends up to 2 fix-LLM calls plus 2 ClickHouse reruns with no cause to work from.
  • Found: a naming trap for the follow-up fix. clickhouse_error_type (posthog/errors.py:79-83) emits CHQueryError<Label> names, but UNDISCLOSED_QUERY_ERROR_TYPES (types.py:13) holds ClickHouseQueryMemoryLimitExceeded. A plain plumb-through of the code would pass the memory-limit case straight through the suppression that exists to hide it.
  • Impact: the machine-readable cause never reaches step classification, the persisted delivery diagnostics, or the report text, so the delivery record stays undiagnosable — the surface AI_REPORT_DIAGNOSTICS_KEY exists for (types.py:102-104).
  • Priority: lowered to should_fix. Two claims in the finding overstate the harm. The cause does reach logs and error tracking: report_pipeline.py:591-599 passes exc_info=last_exc and calls capture_exception(last_exc), and both carry the message text. The per-step UI also does not print the class name — SubscriptionAiReportDelivery.tsx:52,61 collapses a step with no human_readable_error to Failed plus a generic internal-error note. The retry half of the change does work, because MaxToolRetryableError is in _RETRYABLE_QUERY_ERRORS (report_pipeline.py:107-111). Nothing regresses and no data leaks today, so this is an incomplete fix rather than a merge blocker.
Issue description

The status provides a machine-readable cause. This code places the cause only in MaxToolRetryableError text. The subscription pipeline classifies failures with type(exc).__name__. Its safe_error_message() also ignores MaxToolRetryableError. The report therefore records and shows MaxToolRetryableError, not the new cause. This leaves the AI subscription problem unresolved.

Suggested fix

Add an error_code attribute to a query-specific retryable exception. Preserve it when the catch block rethrows the exception. Make the subscription pipeline read this field for diagnostics and apply its existing disclosure policy. Add a pipeline test for the final diagnostic and report copy.

Prompt to fix with AI (copy-paste)
## Context
@ee/hogai/context/insight/query_executor.py#L445-450

<issue_description>
The status provides a machine-readable cause. This code places the cause only in `MaxToolRetryableError` text. The subscription pipeline classifies failures with `type(exc).__name__`. Its `safe_error_message()` also ignores `MaxToolRetryableError`. The report therefore records and shows `MaxToolRetryableError`, not the new cause. This leaves the AI subscription problem unresolved.
</issue_description>

<issue_validation>
- **Checked:** the failure path from `ee/hogai/context/insight/query_executor.py:449-450` into the AI report pipeline (`products/exports/backend/temporal/subscriptions/ai_subscription/report_pipeline.py`), the disclosure helpers in `products/exports/backend/temporal/subscriptions/types.py`, and the delivery UI in `products/subscriptions/frontend/scenes/components/SubscriptionAiReportDelivery.tsx`.
- **Found:** the new `error_code` lives only inside the exception text. `report_pipeline.py:589` sets `type_name = type(last_exc).__name__`, which is `MaxToolRetryableError`. `report_pipeline.py:590` calls `undisclosed_query_error_type(last_exc)`, which walks `__cause__`/`__context__` for a ClickHouse class; the executor raises from a status dict, so that walk finds nothing. `safe_error_message()` (`types.py:47`) matches only `ExposedHogQLError`/`ResolutionError`, so `human_readable_error` stays `None`. The persisted `QueryStepDiagnostic.error_type` therefore records `MaxToolRetryableError`.
- **Found:** the same class name reaches copy a person reads. The step placeholder at `report_pipeline.py:600-602` renders `Query failed to run (MaxToolRetryableError)`, and `GenerateAIReportResult.failure_error()` (`types.py:288-298`) builds the fully-degraded delivery message from those type names.
- **Found:** a second effect of the new retry path. `report_pipeline.py:575` feeds the fix LLM `safe_error_message(exc) or type(exc).__name__`, so each retry asks for a rewrite based on the string `MaxToolRetryableError`. Each failed step now spends up to 2 fix-LLM calls plus 2 ClickHouse reruns with no cause to work from.
- **Found:** a naming trap for the follow-up fix. `clickhouse_error_type` (`posthog/errors.py:79-83`) emits `CHQueryError<Label>` names, but `UNDISCLOSED_QUERY_ERROR_TYPES` (`types.py:13`) holds `ClickHouseQueryMemoryLimitExceeded`. A plain plumb-through of the code would pass the memory-limit case straight through the suppression that exists to hide it.
- **Impact:** the machine-readable cause never reaches step classification, the persisted delivery diagnostics, or the report text, so the delivery record stays undiagnosable — the surface `AI_REPORT_DIAGNOSTICS_KEY` exists for (`types.py:102-104`).
- **Priority:** lowered to `should_fix`. Two claims in the finding overstate the harm. The cause does reach logs and error tracking: `report_pipeline.py:591-599` passes `exc_info=last_exc` and calls `capture_exception(last_exc)`, and both carry the message text. The per-step UI also does not print the class name — `SubscriptionAiReportDelivery.tsx:52,61` collapses a step with no `human_readable_error` to `Failed` plus a generic internal-error note. The retry half of the change does work, because `MaxToolRetryableError` is in `_RETRYABLE_QUERY_ERRORS` (`report_pipeline.py:107-111`). Nothing regresses and no data leaks today, so this is an incomplete fix rather than a merge blocker.
</issue_validation>

## Task
Investigate the issue and solve it

<potential_solution>
Add an `error_code` attribute to a query-specific retryable exception. Preserve it when the catch block rethrows the exception. Make the subscription pipeline read this field for diagnostics and apply its existing disclosure policy. Add a pipeline test for the final diagnostic and report copy.
</potential_solution>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is a valid, but incomplete, gap and I'm escalating it for a human design decision rather than fixing it unattended.

What I confirmed: the query executor puts the real cause only in the exception's message text (Query failed: <code>) and re-raises without a structured field. The AI subscription pipeline classifies failures by exception class name and by walking the exception's cause chain — but the executor raises from a status dictionary, so there is no cause chain to walk. The result is that the persisted per-step diagnostic records MaxToolRetryableError instead of the real ClickHouse cause, even though that diagnostics surface exists specifically to make a degraded report debuggable after the fact. So the structured cause genuinely does not reach classification. (The cause still reaches logs and error tracking, which carry the message text, and nothing regresses today.)

Why I'm not fixing it here: the correct fix has to carry the code as a structured field from the executor (in ee/hogai) into the subscription pipeline (in products/exports), which crosses a product-isolation boundary and lands in a module deliberately kept import-light for the Temporal sandbox. More importantly, the pipeline's error type also feeds recipient-facing report copy, gated by a suppression list — and the two naming schemes diverge: the executor's code uses the CHQueryError… form, while the suppression list uses the ClickHouseQuery… form. A straight plumb-through would therefore risk showing a subscription recipient an error type that the suppression exists to hide. That is a disclosure decision, and there are a few defensible designs for both the field and the naming reconciliation.

What a human needs to decide: (1) how to carry the code as a structured field through the executor's re-raise; (2) how to reconcile the CHQueryError… vs ClickHouseQuery… naming so the suppression policy still holds before this reaches recipient-facing copy; (3) confirm the pipeline change respects the exports product boundary and Temporal-sandbox import constraints. Then a pipeline test should assert the final diagnostic carries the real cause and that a suppressed type stays suppressed in recipient copy.

Comment thread posthog/clickhouse/client/execute_async.py
Comment thread posthog/clickhouse/client/execute_async.py
Comment on lines +445 to +450
# The status hides the message text because it's not user-safe, but error_code
# carries the machine-readable cause (a ClickHouse memory limit, an internal
# error, a worker crash). These are transient, so retry instead of dead-ending
# on an opaque "Query failed" that the report pipeline never retries.
error_code = query_status.get("error_code")
raise MaxToolRetryableError(f"Query failed: {error_code}" if error_code else "Query failed")

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Infrastructure failures enter the SQL rewrite loop

should_fix performance

Why we think it's a valid issue
  • Checked: the error taxonomy in ee/hogai/tool_errors.py, the report pipeline's retry set and fix loop, and what the pre-change code did with the same failure.
  • Found: the codebase already defines the class this case belongs to, and the diff picks the other one. ee/hogai/tool_errors.py:10-11 states the split: MaxToolTransientError is for "Intermittent issues that can be retried without changes (e.g., rate limits, timeouts)", MaxToolRetryableError is for "Solvable issues that can be fixed with adjusted inputs". MaxToolRetryableError.retry_strategy returns "adjusted" (tool_errors.py:86-93), so the hint appended for the LLM is "You may retry with adjusted inputs." (tool_errors.py:44-46). The diff's own comment at ee/hogai/context/insight/query_executor.py:447 calls these failures transient.
  • Found: the cost figure is right. _MAX_QUERY_FIX_RETRIES = 2 (report_pipeline.py:96) and the loop at report_pipeline.py:523-586 runs up to three attempts, so a step can add two fix-LLM calls (30s timeout each, _FIX_LLM_TIMEOUT_SECONDS) plus two more ClickHouse runs. MaxToolRetryableError is in _RETRYABLE_QUERY_ERRORS (report_pipeline.py:107-111), so the class choice alone decides this.
  • Found: the rewrite is blind. report_pipeline.py:575 passes the fixer safe_error_message(exc) or type(exc).__name__, and safe_error_message matches only ExposedHogQLError/ResolutionError (products/exports/backend/temporal/subscriptions/types.py:47), so the fixer receives the literal string MaxToolRetryableError and no cause.
  • Found: the change contradicts the pipeline's stated policy. The comment above _RETRYABLE_QUERY_ERRORS (report_pipeline.py:104-106) says timeouts, infra failures, and generic exceptions fall through to the placeholder without retrying, "since a different SELECT won't fix a ClickHouse outage or a heartbeat timeout".
  • Found: before the change this path did not retry. The old Exception("Query failed") fell into the catch-all at query_executor.py:474-485 and re-raised a plain Exception, which is absent from _RETRYABLE_QUERY_ERRORS. So the extra LLM calls and reruns for hidden-message failures are new with this diff.
  • Impact: during a ClickHouse degradation, every failed step of an AI report now triples its query attempts and spends two blind fix-LLM calls, and Max chat is told to retry with adjusted inputs for a failure no rewrite addresses.
  • Impact: the fix needs a decision rather than a mechanical swap. MaxToolTransientError is not in _RETRYABLE_QUERY_ERRORS, so switching to it removes the retry the PR wanted, and the pipeline has no retry-without-changes path today.
  • Priority: kept at should_fix rather than raised. The load added is bounded and sits on a low-volume scheduled path, and the same wrapping already happens for user-safe infra errors: query_executor.py:443-444 raises APIException(error_message), which the handler at query_executor.py:455-473 already converts into MaxToolRetryableError. The diff widens an existing pattern; it does not create it.
Issue description

Every hidden async failure becomes MaxToolRetryableError. This class enters the report pipeline's query-rewrite loop. Internal failures cannot be fixed by changing SQL. Each step can add two LLM calls and two ClickHouse runs during an outage. These blind retries can increase system load while dependencies are unhealthy.

Suggested fix

Classify error_code before choosing the exception. Use MaxToolRetryableError only when a SQL rewrite can fix the failure. Use MaxToolTransientError for temporary failures and retry once with backoff. Do not send unknown internal failures through the rewrite loop.

Prompt to fix with AI (copy-paste)
## Context
@ee/hogai/context/insight/query_executor.py#L445-450

<issue_description>
Every hidden async failure becomes MaxToolRetryableError. This class enters the report pipeline's query-rewrite loop. Internal failures cannot be fixed by changing SQL. Each step can add two LLM calls and two ClickHouse runs during an outage. These blind retries can increase system load while dependencies are unhealthy.
</issue_description>

<issue_validation>
- **Checked:** the error taxonomy in `ee/hogai/tool_errors.py`, the report pipeline's retry set and fix loop, and what the pre-change code did with the same failure.
- **Found:** the codebase already defines the class this case belongs to, and the diff picks the other one. `ee/hogai/tool_errors.py:10-11` states the split: `MaxToolTransientError` is for "Intermittent issues that can be retried without changes (e.g., rate limits, timeouts)", `MaxToolRetryableError` is for "Solvable issues that can be fixed with adjusted inputs". `MaxToolRetryableError.retry_strategy` returns `"adjusted"` (`tool_errors.py:86-93`), so the hint appended for the LLM is "You may retry with adjusted inputs." (`tool_errors.py:44-46`). The diff's own comment at `ee/hogai/context/insight/query_executor.py:447` calls these failures transient.
- **Found:** the cost figure is right. `_MAX_QUERY_FIX_RETRIES = 2` (`report_pipeline.py:96`) and the loop at `report_pipeline.py:523-586` runs up to three attempts, so a step can add two fix-LLM calls (30s timeout each, `_FIX_LLM_TIMEOUT_SECONDS`) plus two more ClickHouse runs. `MaxToolRetryableError` is in `_RETRYABLE_QUERY_ERRORS` (`report_pipeline.py:107-111`), so the class choice alone decides this.
- **Found:** the rewrite is blind. `report_pipeline.py:575` passes the fixer `safe_error_message(exc) or type(exc).__name__`, and `safe_error_message` matches only `ExposedHogQLError`/`ResolutionError` (`products/exports/backend/temporal/subscriptions/types.py:47`), so the fixer receives the literal string `MaxToolRetryableError` and no cause.
- **Found:** the change contradicts the pipeline's stated policy. The comment above `_RETRYABLE_QUERY_ERRORS` (`report_pipeline.py:104-106`) says timeouts, infra failures, and generic exceptions fall through to the placeholder without retrying, "since a different SELECT won't fix a ClickHouse outage or a heartbeat timeout".
- **Found:** before the change this path did not retry. The old `Exception("Query failed")` fell into the catch-all at `query_executor.py:474-485` and re-raised a plain `Exception`, which is absent from `_RETRYABLE_QUERY_ERRORS`. So the extra LLM calls and reruns for hidden-message failures are new with this diff.
- **Impact:** during a ClickHouse degradation, every failed step of an AI report now triples its query attempts and spends two blind fix-LLM calls, and Max chat is told to retry with adjusted inputs for a failure no rewrite addresses.
- **Impact:** the fix needs a decision rather than a mechanical swap. `MaxToolTransientError` is not in `_RETRYABLE_QUERY_ERRORS`, so switching to it removes the retry the PR wanted, and the pipeline has no retry-without-changes path today.
- **Priority:** kept at `should_fix` rather than raised. The load added is bounded and sits on a low-volume scheduled path, and the same wrapping already happens for user-safe infra errors: `query_executor.py:443-444` raises `APIException(error_message)`, which the handler at `query_executor.py:455-473` already converts into `MaxToolRetryableError`. The diff widens an existing pattern; it does not create it.
</issue_validation>

## Task
Investigate the issue and solve it

<potential_solution>
Classify error_code before choosing the exception. Use MaxToolRetryableError only when a SQL rewrite can fix the failure. Use MaxToolTransientError for temporary failures and retry once with backoff. Do not send unknown internal failures through the rewrite loop.
</potential_solution>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is a valid finding, and I'm escalating it for a human design decision rather than making an unattended change.

What I confirmed: a hidden-message async failure is now raised as the "retryable" error class, which the report pipeline treats as "the SQL is wrong, ask the LLM to rewrite it." But these are transient infrastructure failures (a ClickHouse outage, a worker crash), and no rewrite can fix them. Because the pipeline can't extract a cause from this error, it feeds the fixer only the literal class name and does a blind rewrite. During a ClickHouse degradation, each failed report step can add two 30-second fix-LLM calls plus two more query reruns, and Max chat is told to "retry with adjusted inputs" for something no input change addresses. That contradicts the pipeline's own stated policy that infra failures should not be retried.

Why this isn't a mechanical swap: the error taxonomy has a class that fits exactly — the transient class, meaning "retry once without changes." But that class is not in the pipeline's retryable set, so simply switching to it would remove the retry this PR was specifically written to add (before the PR, this failure did not retry at all). Reconciling "these should retry" with "don't blindly rewrite them" requires a capability the pipeline doesn't have today: retrying the same query without invoking the fix LLM.

What a human needs to decide: (1) the intended retry policy for hidden-message infra failures — retry the same SQL without a rewrite, or don't retry in the report pipeline at all; (2) if retrying, add the transient error class to the pipeline's retryable set together with a branch in the fix loop that skips the fix-LLM and reruns as-is; (3) how this interacts with Max chat, which consumes the same executor and shows the LLM a different retry hint depending on the class. This is best decided together with the related thread about carrying the structured cause through to classification, since the right class depends on that cause being available.

@trunk-io

trunk-io Bot commented Aug 26, 2026

Copy link
Copy Markdown

Static BadgeStatic BadgeStatic Badge

View Full Report ↗︎Docs

The async query status now carries the exception or ClickHouse error type
in error_code for non-user-safe failures, not only the DRF exception code.
Update the QueryStatus.error_code schema description so the documented public
contract matches what the field can hold, including when the message text is
hidden. Regenerated schema.json and posthog/schema.py from the TypeScript
source.

Generated-By: PostHog Desktop
Task-Id: de4edea4-3d44-49da-93d3-0a39abdc791b
@stamphog
stamphog Bot dismissed their stale review August 26, 2026 18:33

New commits were pushed — dismissing the stamphog approval from an earlier head. This PR no longer qualifies for automatic review.

tests-posthog Bot and others added 2 commits August 26, 2026 18:39
A non-user-safe async query failure that happens outside execute_process_query
— concurrency-limit exhaustion after retries, or worker loss — reaches the
Celery on_failure callback instead of the handler the PR already fixed. That
callback set no error_code, so the AI executor still saw an opaque "Query
failed" for this path.

Record the exception class as a machine-readable error_code in
_process_query_task_failure for non-user-safe failures, mirroring
execute_process_query, so the AI executor and polling clients get the cause.
The user-safe APIException branch is unchanged (it already surfaces a message).

Parameterize the existing callback test to cover both the APIException path and
a non-API failure that must now carry the class name as its code.

Generated-By: PostHog Desktop
Task-Id: de4edea4-3d44-49da-93d3-0a39abdc791b
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants