From 9825afda8b837c4583d4dfbdd001cb1a2ddf7cb5 Mon Sep 17 00:00:00 2001 From: guangyu-reflexio Date: Sun, 13 Sep 2026 02:07:01 -0700 Subject: [PATCH] feat(storage): name the missing-tenant-schema error so callers can branch on it `handle_exceptions` collapses every driver exception into an anonymous `StorageError` carrying a formatted message, which erases the exception class. That is fine for the general case, but it leaves one condition unrecoverable at the caller: "the tenant schema this org addresses does not exist". The rendered text is `InvalidSchemaName: schema "org_N" does not exist`, which matches neither `isinstance(exc, psycopg2.errors.InvalidSchemaName)` (the type is gone) nor an `"invalid schema"` substring (no space in the class name). So a caller that wants to treat a vanished tenant differently from a transient fault -- e.g. park the org instead of re-querying it every few minutes -- has nothing to branch on. Adds `TenantSchemaMissingError(StorageError)`, following the existing `UserPlaybookRetentionHoldActiveError` precedent: classify at raise time, hand the caller a name. It is a subclass, so every existing `except StorageError` handler is unaffected. This is a classification, not a softening -- a missing schema for a verified org is a real provisioning failure and still logs at ERROR. --- reflexio/server/services/storage/error.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/reflexio/server/services/storage/error.py b/reflexio/server/services/storage/error.py index 3a04c301..13e101ea 100644 --- a/reflexio/server/services/storage/error.py +++ b/reflexio/server/services/storage/error.py @@ -34,6 +34,27 @@ class OpenWorldQualificationConflictError(StorageError): """Raised when a cached qualification key resolves to a conflicting result.""" +class TenantSchemaMissingError(StorageError): + """Raised when a tenant schema the caller addressed does not exist or is unexposed. + + ``handle_exceptions`` collapses every driver exception into an anonymous + ``StorageError`` carrying a formatted message, which erases the exception + class. Callers that need to branch on "this org's schema is gone" — the + durable-learning resume sweep, which parks such an org rather than + re-querying it every tick — cannot recover that fact from the message: + the rendered text is ``InvalidSchemaName: schema "org_N" does not exist``, + which matches neither ``isinstance(..., psycopg2.errors.InvalidSchemaName)`` + nor the ``"invalid schema"`` substring (no space in ``InvalidSchemaName``). + + So the classification is carried as a TYPE. Same rationale as + ``UserPlaybookRetentionHoldActiveError``: the caller needs a name it can + branch on rather than an anonymous ``StorageError``. + + This is a classification, not a softening — for a verified org a missing + schema is still a real provisioning failure and still logs at ERROR. + """ + + def require_non_empty_session_id(value: Any) -> str: """Return a stripped, non-empty request ``session_id`` or raise ``StorageError``.