Skip to content

fix(dao): don't mask transient OperationalError as a "not found" result - #43479

Merged
aminghadersohi merged 11 commits into
apache:masterfrom
aminghadersohi:fix-dao-operationalerror-sc117473
Aug 27, 2026
Merged

fix(dao): don't mask transient OperationalError as a "not found" result#43479
aminghadersohi merged 11 commits into
apache:masterfrom
aminghadersohi:fix-dao-operationalerror-sc117473

Conversation

@aminghadersohi

@aminghadersohi aminghadersohi commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Why

A transient connection-level failure — in production, psycopg2.OperationalError: SSL connection has been closed unexpectedly — was being masked by three DAO lookups in superset/daos/base.py, so a server-side database blip was reported to users as a client error about a record that does not exist:

  • find_by_ids caught SQLAlchemyError and re-raised it as DAOFindFailedError, which carries status = 400. A real "record doesn't exist" never reaches this handler (.all() returns []), so in practice it only fired on genuine execution failures — turning a connection drop into "AnnotationLayer 5 doesn't exist" with HTTP 400, sending users and support chasing a misconfiguration that does not exist.
  • find_by_id_or_uuid and _find_by_column catch StatementError to absorb type-coercion errors (e.g. a non-UUID string) and return None. Because OperationalError is a subclass of StatementError (MRO: OperationalError → DatabaseError → DBAPIError → StatementError → SQLAlchemyError), these two also swallowed a transient connection failure and returned None — which every caller reads as "the record does not exist." This is arguably worse than the find_by_ids case: it is a confidently wrong answer with no error at all, invisible in logs and error tracking.

All three share one root cause and one remedy, so they are fixed together.

What

Add a narrow, earlier except OperationalError: raise ahead of the existing broad handler at each of the three sites. Connection-level failures now propagate as themselves (surfacing as a 5xx) while the intended behavior is preserved exactly for every other subtype — other SQLAlchemyErrors still become DAOFindFailedError, and genuine coercion StatementErrors still return None.

Order matters: the OperationalError clause must precede the broader StatementError / SQLAlchemyError clause.

The same masking existed one layer up, in the API error handler. handle_api_exception mapped exc.DatabaseError to HTTP 422, and OperationalError is a DatabaseError subclass — so on every route wrapped by that decorator (which includes get_headless / get_list_headless / post_headless / put_headless / delete_headless on BaseSupersetModelRestApi, i.e. essentially all model CRUD) the propagated error still reached the caller as a client error. The DAO fix alone would have traded a misleading 400 for a misleading 422. superset/views/error_handling.py now matches OperationalError in its own clause ahead of the 422 handler and returns 500; other DatabaseError subtypes (bad SQL, missing relation, constraint violations) still return 422 unchanged. Credit to @codeant-ai-for-open-source for catching this.

Why OperationalError as the boundary? It covers both mid-query connection drops and initial-connect failures, and is simpler and safer than narrowing on DBAPIError.connection_invalidated, which misses initial-connect failures (a connection that was never established cannot be invalidated). Some drivers do raise a few non-connection problems as OperationalError; the cost of letting one of those through is a 500 instead of a 400 on an already-failing request — the safer direction, since it fails loudly rather than fabricating a "not found".

Blast radius

Two files, both on the error path only; the happy path and all non-connection error handling are unchanged.

  • superset/daos/base.py — the shared BaseDAO lookup helpers. Transient connection failures surface as themselves instead of a misleading 400 / silent None.
  • superset/views/error_handling.pyhandle_api_exception. Connection failures return 500 instead of 422.

One consequence worth calling out for review: a few routes under handle_api_exception query user-configured analytic databases rather than the metadata DB (DatabaseRestApi.tables, Api.query, the Datasource views, fetch_datasource_metadata). An unreachable analytic database on those routes now returns 500 where it previously returned 422. That seems like the right direction — the request was processable, the upstream was down — and it is the same defect class this PR addresses, but it is a wider behavior change than the DAO fix and maintainers may want to weigh in. Likewise, 500 was chosen to match the decorator's own generic server-error fallback; 503 would signal "transient, retry" more precisely if preferred.

How to test

Regression tests added in tests/unit_tests/dao/base_dao_test.py, covering both legs at each affected site:

  • find_by_ids: an OperationalError from query.all() propagates as OperationalError; a non-connection SQLAlchemyError still raises DAOFindFailedError (existing tests).
  • find_by_id_or_uuid / _find_by_column: an OperationalError propagates, while a genuine coercion StatementError still returns None.

And in tests/unit_tests/views/test_error_handling.py, for the handler:

  • OperationalError through handle_api_exception returns 500.
  • ProgrammingError and IntegrityError still return 422.
  • A driver message quoting a hostname, IP and port is replaced with the generic text for embedded guest viewers, and none of those substrings appear in the response body.
  • The same message is kept for regular users — the redaction is guest-only.
pytest tests/unit_tests/dao/base_dao_test.py tests/unit_tests/views/test_error_handling.py

Each negative test asserts the exception type, not merely that something was raised. Reverting the production change makes every propagation test fail (the OperationalError is masked as DAOFindFailedError or None instead).

Risk & rollback

Low. The change is additive (one import + three identical guards in the DAO, one except clause in the handler) and only redirects an already-failing request from a misleading 400/422/None to an honest 500. Rollback is a plain revert. No migration, no feature flag, no schema change. The one judgment call is the analytic-database consequence noted under Blast radius.

Guest-token exposure: none. The new clause hands a bare string to json_error_response, whose string branch routes it through sanitize_error_message, so an embedded viewer receives the generic message rather than the driver text (host/IP/port). This is inherited from json_error_response and is the same protection the adjacent 422 clause already relies on; regression tests now pin both legs.

A transient connection-level failure (e.g. "SSL connection has been closed
unexpectedly") surfaces as psycopg2/SQLAlchemy OperationalError. Three DAO
lookups swallowed it:

- find_by_ids wrapped it in DAOFindFailedError (HTTP 400), reporting a server
  connection drop to the user as "<Model> <id> doesn't exist".
- find_by_id_or_uuid and _find_by_column catch StatementError to absorb
  type-coercion errors and return None. Because OperationalError is a
  StatementError subclass, they silently returned None as well, which callers
  read as "record not found" with nothing surfaced in logs or Sentry.

Add a narrow, earlier 'except OperationalError: raise' at each site so
connection-level failures propagate as themselves (surfacing as a 5xx) while
the intended behavior for other SQLAlchemyError/StatementError subtypes is
preserved. Add regression tests for both paths at all three sites.
These chart/dashboard DAO unit tests ran find_by_id against an in-memory
SQLite schema whose tables were never created, and depended on the DAO
swallowing the resulting OperationalError to return None. With the masking
removed they now fail with 'no such table'. Point them at the existing
session_with_data fixture so the tables exist and a not-found lookup is a
genuine empty result. The favorite tests were passing vacuously (the
'if not <obj>: return' guard always fired because the object was never
found); with the object now present they exercise the favorite path for
real, so replace the guard with an assertion.
@dosubot dosubot Bot added the change:backend Requires changing the backend label Aug 24, 2026
@bito-code-review

bito-code-review Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Code Review Agent Run #fecaa6

Actionable Suggestions - 0
Review Details
  • Files reviewed - 4 · Commit Range: 4059311..963fe7a
    • superset/daos/base.py
    • tests/unit_tests/charts/dao/dao_tests.py
    • tests/unit_tests/dao/base_dao_test.py
    • tests/unit_tests/dashboards/dao_tests.py
  • Files skipped - 0
  • Tools
    • MyPy (Static Code Analysis) - ✔︎ Successful
    • Astral Ruff (Static Code Analysis) - ✔︎ Successful
    • Whispers (Secret Scanner) - ✔︎ Successful
    • Detect-secrets (Secret Scanner) - ✔︎ Successful

Bito Usage Guide

Commands

Type the following command in the pull request comment and save the comment.

  • /review - Manually triggers an incremental AI Review.

  • /review full - Manually triggers a full AI Review.

  • /pause - Pauses automatic reviews on this pull request.

  • /resume - Resumes automatic reviews.

  • /resolve - Marks all Bito-posted review comments as resolved.

  • /abort - Cancels all in-progress reviews.

Refer to the documentation for additional commands.

Configuration

This repository uses Superset You can customize the agent settings here or contact your Bito workspace admin at evan@preset.io.

Documentation & Help

AI Code Review powered by Bito Logo

@netlify

netlify Bot commented Aug 24, 2026

Copy link
Copy Markdown

Deploy Preview for superset-docs-preview ready!

Name Link
🔨 Latest commit 91c6e8c
🔍 Latest deploy log https://app.netlify.com/projects/superset-docs-preview/deploys/6a8f43967808a700083a3163
😎 Deploy Preview https://deploy-preview-43479--superset-docs-preview.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

Comment thread superset/daos/base.py
@bito-code-review

Copy link
Copy Markdown
Contributor

The flagged issue is correct. In superset/daos/base.py, the OperationalError is caught and re-raised, but if the surrounding API layer catches it as a generic SQLAlchemyError (or DatabaseError), it may be mapped to a 422 Unprocessable Entity instead of a 5xx Server Error. To resolve this, you should ensure that OperationalError is explicitly handled by the API exception handler to return a 500 status code, or wrap it in a custom exception that the API layer maps to 500.

Since I do not have access to the API exception handler code in this diff, I recommend checking where DAOFindFailedError or other DAO exceptions are caught in your API controllers and adding an explicit handler for OperationalError there.

superset/daos/base.py

except OperationalError:
            # A transient connection-level failure (e.g. the server dropping the
            # connection mid-query) surfaces as OperationalError. Let it propagate
            # as a 5xx instead of masking it as a 400 "record doesn't exist".
            raise

@codecov

codecov Bot commented Aug 25, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 79.11%. Comparing base (a140e74) to head (9d37390).
⚠️ Report is 12 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff             @@
##           master   #43479      +/-   ##
==========================================
+ Coverage   79.10%   79.11%   +0.01%     
==========================================
  Files        2878     2878              
  Lines      165634   165650      +16     
  Branches    38294    38296       +2     
==========================================
+ Hits       131023   131061      +38     
+ Misses      32123    32112      -11     
+ Partials     2488     2477      -11     
Flag Coverage Δ
hive 37.95% <18.18%> (-0.01%) ⬇️
mysql 57.69% <27.27%> (-0.02%) ⬇️
postgres 57.73% <36.36%> (-0.02%) ⬇️
presto 39.86% <18.18%> (-0.01%) ⬇️
python 83.70% <100.00%> (+0.02%) ⬆️
sqlite 57.42% <27.27%> (-0.02%) ⬇️
unit 73.84% <100.00%> (+0.07%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

aminghadersohi and others added 2 commits August 25, 2026 15:58
`handle_api_exception` mapped `exc.DatabaseError` to HTTP 422. Because
`OperationalError` is a `DatabaseError` subclass, a connection-level
failure propagated by the `BaseDAO` lookup helpers still reached callers
as a client error on every route wrapped by that decorator — which
includes the `get/get_list/post/put/delete` handlers on every model REST
API.

Match `OperationalError` in its own clause ahead of the 422 handler and
return 500. Other `DatabaseError` subtypes (bad SQL, missing relation)
still return 422 unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
@bito-code-review

bito-code-review Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Code Review Agent Run #a6c53c

Actionable Suggestions - 0
Review Details
  • Files reviewed - 2 · Commit Range: 963fe7a..28dd709
    • superset/views/error_handling.py
    • tests/unit_tests/views/test_error_handling.py
  • Files skipped - 0
  • Tools
    • MyPy (Static Code Analysis) - ✔︎ Successful
    • Astral Ruff (Static Code Analysis) - ✔︎ Successful
    • Whispers (Secret Scanner) - ✔︎ Successful
    • Detect-secrets (Secret Scanner) - ✔︎ Successful

Bito Usage Guide

Commands

Type the following command in the pull request comment and save the comment.

  • /review - Manually triggers an incremental AI Review.

  • /review full - Manually triggers a full AI Review.

  • /pause - Pauses automatic reviews on this pull request.

  • /resume - Resumes automatic reviews.

  • /resolve - Marks all Bito-posted review comments as resolved.

  • /abort - Cancels all in-progress reviews.

Refer to the documentation for additional commands.

Configuration

This repository uses Superset You can customize the agent settings here or contact your Bito workspace admin at evan@preset.io.

Documentation & Help

AI Code Review powered by Bito Logo

@aminghadersohi

aminghadersohi commented Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

Requested review from @eschutho — she owns the recent history on superset/views/error_handling.py (the uncaught-SupersetException status/log-level mapping and the SSH-tunnel logging downgrade, both this month), which is exactly the second half of this diff.

cc @mikebridge — you own the recent superset/daos/base.py work (SoftDeleteMixin, LIKE-operator semantics). GitHub won't let me formally request you since you're not an ASF collaborator, but your eyes on the three DAO guards would be valuable.

Two things worth a reviewer's attention:

  1. Analytic-DB routes — as the author notes under Blast radius, DatabaseRestApi.tables, Api.query and the Datasource views now return 500 instead of 422 when a user-configured warehouse is unreachable. That's a wider behavior change than the DAO fix and deserves an explicit call.
  2. Error-message content on the new 500 path — the new clause returns utils.error_msg_from_exception(ex), i.e. the raw driver message. Given the recent fix(embedded): redact database errors in API responses to guest users change, worth confirming that connection strings / hostnames inside an OperationalError can't leak through this new clause on guest-token routes.

Otherwise the diff matches the PR body: three additive except OperationalError: raise guards placed before the broader handlers, plus one clause ahead of the 422 handler — no existing behavior removed, and the tests assert exception type on both legs at each site.

@eschutho eschutho left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Posting on Elizabeth's behalf — this is her PR reviewer agent. Forward any pushback to her and she'll loop me back in.

This looks good overall — a well-scoped, well-tested fix, and I'm approving. Just two optional notes below on judgment calls you already flagged yourself in the description; neither blocks merge. All line numbers verified against HEAD 28dd709f.

superset/views/error_handling.py:154-162

On the 500-vs-503 question you raised: I checked for existing precedent and DatabaseRestApi.schemas (superset/databases/api.py:837-840) already catches OperationalError and returns 500 with the same reasoning, so this change actually makes the codebase more internally consistent rather than introducing a new convention. 503 would be marginally more precise semantically (it'd opt into SUPERSET_CLIENT_RETRY_STATUS_CODES), but there's no existing "503 for a transient DB blip" pattern here to align with.

WDYT — keep 500 as-is, or worth a follow-up issue to consider 503 codebase-wide later? Happy either way.

superset/daos/base.py:446-450 (and the two sibling guards at :258-262, :349-353)

On the analytic-database blast radius you called out: I traced the affected routes (DatabaseRestApi.tables, Api.query, the datasource views) and most analytic-DB failures are already wrapped into SupersetErrorException by the engine-spec/command layer before they'd reach this clause raw, so the practical surface is smaller than it might look. An unreachable analytic DB going from 422 to 500 also seems like the more honest status either way (it's not a client-fixable "unprocessable" request).

Totally optional, but if you want to narrow this further down the line, distinguishing metadata-DB vs analytic-DB OperationalError would need connection provenance threaded through — not something this PR needs to solve now.

@eschutho eschutho left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Posting on Elizabeth's behalf — this is her PR reviewer agent. Forward any pushback to her and she'll loop me back in.

This looks good overall — a well-scoped, well-tested fix, and I'm approving. Just two optional notes below on judgment calls you already flagged yourself in the description; neither blocks merge. All line numbers verified against HEAD 28dd709f.

superset/views/error_handling.py:154-162

On the 500-vs-503 question you raised: I checked for existing precedent and DatabaseRestApi.schemas (superset/databases/api.py:837-840) already catches OperationalError and returns 500 with the same reasoning, so this change actually makes the codebase more internally consistent rather than introducing a new convention. 503 would be marginally more precise semantically (it'd opt into SUPERSET_CLIENT_RETRY_STATUS_CODES), but there's no existing "503 for a transient DB blip" pattern here to align with.

WDYT — keep 500 as-is, or worth a follow-up issue to consider 503 codebase-wide later? Happy either way.

superset/daos/base.py:446-450 (and the two sibling guards at :258-262, :349-353)

On the analytic-database blast radius you called out: I traced the affected routes (DatabaseRestApi.tables, Api.query, the datasource views) and most analytic-DB failures are already wrapped into SupersetErrorException by the engine-spec/command layer before they'd reach this clause raw, so the practical surface is smaller than it might look. An unreachable analytic DB going from 422 to 500 also seems like the more honest status either way (it's not a client-fixable "unprocessable" request).

Totally optional, but if you want to narrow this further down the line, distinguishing metadata-DB vs analytic-DB OperationalError would need connection provenance threaded through — not something this PR needs to solve now.

aminghadersohi and others added 2 commits August 27, 2026 14:54
The new 500 clause passes the raw driver message to `json_error_response`,
which routes a bare string through `sanitize_error_message`. Driver
messages quote the host, port and user of the failed connection, so pin
the redaction down for embedded guest viewers — and pin down that regular
users still get the detail.

Co-Authored-By: Claude <noreply@anthropic.com>
@bito-code-review

bito-code-review Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Code Review Agent Run #3724a0

Actionable Suggestions - 0
Review Details
  • Files reviewed - 1 · Commit Range: 28dd709..9d37390
    • tests/unit_tests/views/test_error_handling.py
  • Files skipped - 0
  • Tools
    • MyPy (Static Code Analysis) - ✔︎ Successful
    • Astral Ruff (Static Code Analysis) - ✔︎ Successful
    • Whispers (Secret Scanner) - ✔︎ Successful
    • Detect-secrets (Secret Scanner) - ✔︎ Successful

Bito Usage Guide

Commands

Type the following command in the pull request comment and save the comment.

  • /review - Manually triggers an incremental AI Review.

  • /review full - Manually triggers a full AI Review.

  • /pause - Pauses automatic reviews on this pull request.

  • /resume - Resumes automatic reviews.

  • /resolve - Marks all Bito-posted review comments as resolved.

  • /abort - Cancels all in-progress reviews.

Refer to the documentation for additional commands.

Configuration

This repository uses Superset You can customize the agent settings here or contact your Bito workspace admin at evan@preset.io.

Documentation & Help

AI Code Review powered by Bito Logo

@aminghadersohi
aminghadersohi merged commit bcc6af6 into apache:master Aug 27, 2026
75 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

change:backend Requires changing the backend size/L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants