Skip to content

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

Open
aminghadersohi wants to merge 5 commits into
apache:masterfrom
aminghadersohi:fix-dao-operationalerror-sc117473
Open

fix(dao): don't mask transient OperationalError as a "not found" result#43335
aminghadersohi wants to merge 5 commits into
apache:masterfrom
aminghadersohi:fix-dao-operationalerror-sc117473

Conversation

@aminghadersohi

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.

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

superset/daos/base.py only — the shared BaseDAO lookup helpers used across the app. Behavior changes solely on the error path: transient DB connection failures now surface as 5xx instead of a misleading 400 / silent None. The happy path and all non-connection error handling are unchanged.

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.

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).

pytest tests/unit_tests/dao/base_dao_test.py

Risk & rollback

Very low. The change is additive (one import + three identical 5-line guards) and only redirects an already-failing request from a misleading 400/None to an honest 5xx/propagated error. Rollback is a plain revert. No migration, no feature flag, no schema change.

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

codecov Bot commented Aug 19, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 28.57143% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 66.67%. Comparing base (c2d653b) to head (963fe7a).

Files with missing lines Patch % Lines
superset/daos/base.py 28.57% 5 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master   #43335      +/-   ##
==========================================
- Coverage   66.73%   66.67%   -0.07%     
==========================================
  Files        2876     2876              
  Lines      164201   164174      -27     
  Branches    37887    37873      -14     
==========================================
- Hits       109577   109459     -118     
- Misses      52467    52558      +91     
  Partials     2157     2157              
Flag Coverage Δ
hive 38.10% <14.28%> (-0.01%) ⬇️
mysql 57.76% <14.28%> (-0.01%) ⬇️
postgres 57.79% <28.57%> (-0.01%) ⬇️
presto 40.04% <14.28%> (-0.01%) ⬇️
python 59.17% <28.57%> (-0.01%) ⬇️
sqlite 57.43% <14.28%> (-0.01%) ⬇️
unit 100.00% <ø> (ø)

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.

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.
@aminghadersohi
aminghadersohi marked this pull request as ready for review August 19, 2026 17:58
@aminghadersohi
aminghadersohi requested a lite review from Copilot August 19, 2026 17:59
@dosubot dosubot Bot added the change:backend Requires changing the backend label Aug 19, 2026

Copilot AI 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.

Pull request overview

Warning

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Prevents transient database connection issues (OperationalError) from being incorrectly treated as “not found” results (400/None) in shared DAO lookup helpers, and adds regression tests to ensure these errors propagate as 5xx-worthy failures.

Changes:

  • Update BaseDAO.find_by_id_or_uuid, BaseDAO._find_by_column, and BaseDAO.find_by_ids to re-raise OperationalError before broader exception handlers.
  • Add unit tests asserting OperationalError propagates while existing StatementError/SQLAlchemyError behavior remains unchanged.
  • Adjust chart/dashboard DAO tests to rely on data-seeded session fixtures and assert expected records exist.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

File Description
superset/daos/base.py Re-raises OperationalError so transient DB failures aren’t masked as “not found”.
tests/unit_tests/dao/base_dao_test.py Adds regression tests for OperationalError propagation vs. StatementError absorption.
tests/unit_tests/charts/dao/dao_tests.py Uses seeded session fixture and asserts chart exists for favorite tests.
tests/unit_tests/dashboards/dao_tests.py Uses seeded session fixture and asserts dashboard exists for favorite tests.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread tests/unit_tests/dao/base_dao_test.py
Comment thread tests/unit_tests/dao/base_dao_test.py
Comment thread superset/daos/base.py
@bito-code-review

bito-code-review Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Code Review Agent Run #b7ff87

Actionable Suggestions - 0
Additional Suggestions - 3
  • superset/daos/base.py - 2
    • Comment inaccuracy about exception hierarchy · Line 260-260
      Comment incorrectly claims OperationalError is 'a StatementError subclass'; the exception hierarchy shows OperationalError inherits from DBAPIError, not StatementError. This factual inaccuracy could mislead future maintainers about exception handling logic.
    • Comment inaccuracy (duplicate of line 260) · Line 351-351
      Identical factual error to line 260: comment claims OperationalError is 'a StatementError subclass' when it actually inherits from DBAPIError. The logic is correct; only the comment is wrong.
  • tests/unit_tests/dashboards/dao_tests.py - 1
    • Inline import violation · Line 26-26
      Inline import inside fixture violates BITO.md import conventions. Move `from superset.models.core import FavStar # noqa: F401` to module level.
Filtered by Review Rules

Bito filtered these suggestions based on rules created automatically for your feedback. Manage rules.

  • superset/daos/base.py - 3
Review Details
  • Files reviewed - 4 · Commit Range: 4059311..1c44dbc
    • 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 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

Copy link
Copy Markdown
Contributor Author

Thanks @bito-code-review — went through the three suggestions:

"OperationalError is not a StatementError subclass" (base.py L260/L351) — It is, transitively, and that transitivity is the whole point of the comment:

>>> from sqlalchemy.exc import OperationalError, StatementError
>>> issubclass(OperationalError, StatementError)
True

MRO: OperationalError → DatabaseError → DBAPIError → StatementError → SQLAlchemyError. DBAPIError itself subclasses StatementError, so the except StatementError on the next line does catch OperationalError — which is exactly why the earlier except OperationalError: raise guard is required. The comment describes the relationship that governs the control flow, so it's accurate as written. Keeping as-is.

Move FavStar import to module level (dashboards/dao_tests.py L26) — These fixtures import Superset models inside the fixture by design; the very next line (from superset.models.dashboard import Dashboard) does the same, as does every test's inline DAO import. The inline import keeps model registration ordered relative to the in-memory metadata setup, and hoisting only FavStar would be inconsistent with the surrounding file. Keeping as-is.

The logic in all cases was already flagged as correct — these are comment/style notes, so no code change.

@aminghadersohi

Copy link
Copy Markdown
Contributor Author

cc @mikebridge — you have the most recent substantive work on BaseDAO's lookup helpers in superset/daos/base.py, so flagging this for your eyes on the except-clause ordering and the OperationalError / StatementError MRO argument. (Couldn't add you as a formal reviewer — not a collaborator on this repo.)

@aminghadersohi
aminghadersohi requested review from Vitor-Avila and rusackas and removed request for rusackas August 20, 2026 00:41
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