Skip to content

popoto.exceptions

popoto.exceptions

Custom exceptions for the Popoto Redis ORM library.

ModelException

Bases: Exception

Base exception for all Popoto ORM model-related errors.

Source code in src/popoto/exceptions.py
class ModelException(Exception):
    """Base exception for all Popoto ORM model-related errors."""

    pass

QueryException

Bases: Exception

Raised when a query is malformed or produces an unexpected result.

Source code in src/popoto/exceptions.py
class QueryException(Exception):
    """Raised when a query is malformed or produces an unexpected result."""

    pass

PublisherException

Bases: Exception

Raised when a publish operation fails.

Source code in src/popoto/exceptions.py
class PublisherException(Exception):
    """Raised when a publish operation fails."""

    pass

SubscriberException

Bases: Exception

Raised when a subscriber's message handler fails.

Source code in src/popoto/exceptions.py
class SubscriberException(Exception):
    """Raised when a subscriber's message handler fails."""

    pass

KeyMutationError

Bases: ModelException

Raised when a KeyField value is changed after initial save.

KeyField values form the Redis storage key (identity) of a model instance. Changing them silently would delete the old key and create a new one, potentially orphaning references. This exception prevents accidental identity changes.

To intentionally migrate a key, use save(migrate_key=True).

Example::

instance = MyModel.query.get(name="old_name")
instance.name = "new_name"
instance.save()  # Raises KeyMutationError

# Intentional migration:
instance.save(migrate_key=True)  # Succeeds
Source code in src/popoto/exceptions.py
class KeyMutationError(ModelException):
    """Raised when a KeyField value is changed after initial save.

    KeyField values form the Redis storage key (identity) of a model instance.
    Changing them silently would delete the old key and create a new one,
    potentially orphaning references. This exception prevents accidental
    identity changes.

    To intentionally migrate a key, use ``save(migrate_key=True)``.

    Example::

        instance = MyModel.query.get(name="old_name")
        instance.name = "new_name"
        instance.save()  # Raises KeyMutationError

        # Intentional migration:
        instance.save(migrate_key=True)  # Succeeds
    """

    pass

CorruptFieldError

Bases: ModelException

Raised when a model hash field cannot be decoded (#573, #476).

Popoto's decode path is corruption-tolerant for non-key fields: an undecodable value is quarantined rather than fatal — its raw bytes stay untouched in Redis, the attribute reads as the field's declared default, a WARNING is logged on the POPOTO.encoding logger, and the raw bytes are recorded in instance._corrupt_fields. Losing one field costs one field, not the whole record.

This exception is raised in the two places where tolerance would be worse than failure:

  1. A corrupt KeyField. A defaulted or skipped key is a wrong identity. save()'s two safety nets (KeyMutationError and the obsolete-key branch) are both blinded by the same recomputation, so the row would be duplicated to a second hash with no exception and no log line — the silent-duplication mechanism documented in #537/#538. The message names the model, the Redis key and the field so an operator can inspect the row with redis-cli HGETALL.

  2. A save() that would overwrite quarantined bytes. While a field being written is still quarantined, save() refuses rather than packing the declared default over the preserved bytes. The refusal is scoped to the fields actually written, so save(update_fields=["unrelated"]) on a poisoned row still succeeds.

Repair path — assigning a value clears the quarantine::

obj = MyModel.query.get(name="Alice")
obj._corrupt_fields          # {'bio': b'$IndexF:MyModel:status:active'}
obj.save()                   # raises CorruptFieldError
obj.bio = "recovered text"   # clears the quarantine
obj.save()                   # succeeds; the row is clean

Set POPOTO_DECODE_QUARANTINE_DISABLE=1 to restore the pre-#573 reader, which raises the underlying decode exception for every corrupt field.

Source code in src/popoto/exceptions.py
class CorruptFieldError(ModelException):
    """Raised when a model hash field cannot be decoded (#573, #476).

    Popoto's decode path is corruption-tolerant for *non-key* fields: an
    undecodable value is **quarantined** rather than fatal — its raw bytes stay
    untouched in Redis, the attribute reads as the field's declared default,
    a ``WARNING`` is logged on the ``POPOTO.encoding`` logger, and the raw
    bytes are recorded in ``instance._corrupt_fields``. Losing one field costs
    one field, not the whole record.

    This exception is raised in the two places where tolerance would be worse
    than failure:

    1. **A corrupt KeyField.** A defaulted or skipped key is a *wrong identity*.
       ``save()``'s two safety nets (``KeyMutationError`` and the obsolete-key
       branch) are both blinded by the same recomputation, so the row would be
       duplicated to a second hash with no exception and no log line — the
       silent-duplication mechanism documented in #537/#538. The message names
       the model, the Redis key and the field so an operator can inspect the
       row with ``redis-cli HGETALL``.

    2. **A ``save()`` that would overwrite quarantined bytes.** While a field
       being written is still quarantined, ``save()`` refuses rather than
       packing the declared default over the preserved bytes. The refusal is
       scoped to the fields actually written, so
       ``save(update_fields=["unrelated"])`` on a poisoned row still succeeds.

    Repair path — assigning a value clears the quarantine::

        obj = MyModel.query.get(name="Alice")
        obj._corrupt_fields          # {'bio': b'$IndexF:MyModel:status:active'}
        obj.save()                   # raises CorruptFieldError
        obj.bio = "recovered text"   # clears the quarantine
        obj.save()                   # succeeds; the row is clean

    Set ``POPOTO_DECODE_QUARANTINE_DISABLE=1`` to restore the pre-#573 reader,
    which raises the underlying decode exception for every corrupt field.
    """

    pass

AppendOnlyViolation

Bases: ModelException

Raised by AppendOnlyMixin when a write would destroy a record (#560).

Append-only models accept exactly one write per Redis key and no deletes. Every shape that would overwrite or remove an existing record raises this: re-saving a persisted instance, saving a fresh object whose key collides with a stored one, delete(), delete_all(), and save(migrate_key=True) (a key migration DELETEs the old key, so it is a destroy dressed as a save).

The message names the offending Redis key and never the record's content. That discipline is load-bearing and mirrors NeverRecordException: exception text reaches plaintext log files, so quoting a value would leak it through a side channel.

The documented escape hatch is AppendOnlyMixin.hard_delete(instance), which is retention/admin-only and greppable by design.

Source code in src/popoto/exceptions.py
class AppendOnlyViolation(ModelException):
    """Raised by ``AppendOnlyMixin`` when a write would destroy a record (#560).

    Append-only models accept exactly one write per Redis key and no deletes.
    Every shape that would overwrite or remove an existing record raises this:
    re-saving a persisted instance, saving a fresh object whose key collides
    with a stored one, ``delete()``, ``delete_all()``, and
    ``save(migrate_key=True)`` (a key migration ``DELETE``s the old key, so it
    is a destroy dressed as a save).

    The message names the offending Redis key and never the record's content.
    That discipline is load-bearing and mirrors ``NeverRecordException``:
    exception text reaches plaintext log files, so quoting a value would leak
    it through a side channel.

    The documented escape hatch is ``AppendOnlyMixin.hard_delete(instance)``,
    which is retention/admin-only and greppable by design.
    """

    pass

JournalBlockedError

Bases: ModelException

Raised when a provenance-journal write is refused by the firewall (#560).

ProvenanceJournal scans candidate content with :func:popoto.privacy.never_record.scan_never_record before it issues or queues a single Redis command, so a blocked capture or annotation raises rather than returning a sentinel. That matters most for annotations: a Model.save() blocked by the firewall returns the pipeline in pipeline mode, which is indistinguishable from success, and a caller that queued a supersede on top of it would close a target's validity interval against an annotation that was never written.

Deliberately not a :class:NeverRecordException subclass. That class inherits :class:SkipSaveException so existing handlers silently swallow a filtered save; a journal caller must never silently lose a capture, so this error is outside that hierarchy and propagates.

Carries a content-free verdict (reason code plus detector name) under the same no-quoting rule as :class:NeverRecordException.

Source code in src/popoto/exceptions.py
class JournalBlockedError(ModelException):
    """Raised when a provenance-journal write is refused by the firewall (#560).

    ``ProvenanceJournal`` scans candidate content with
    :func:`popoto.privacy.never_record.scan_never_record` *before* it issues or
    queues a single Redis command, so a blocked capture or annotation raises
    rather than returning a sentinel. That matters most for annotations: a
    ``Model.save()`` blocked by the firewall returns the *pipeline* in pipeline
    mode, which is indistinguishable from success, and a caller that queued a
    supersede on top of it would close a target's validity interval against an
    annotation that was never written.

    Deliberately **not** a :class:`NeverRecordException` subclass. That class
    inherits :class:`SkipSaveException` so existing handlers silently swallow a
    filtered save; a journal caller must never silently lose a capture, so this
    error is outside that hierarchy and propagates.

    Carries a content-free ``verdict`` (reason code plus detector name) under
    the same no-quoting rule as :class:`NeverRecordException`.
    """

    def __init__(
        self, message: str, verdict: Optional["NeverRecordVerdict"] = None
    ) -> None:
        super().__init__(message)
        #: The blocking ``NeverRecordVerdict``, or ``None``. Content-free.
        self.verdict = verdict

SkipSaveException

Bases: ModelException

Raised by WriteFilterMixin to silently abort a save operation.

When a model's compute_filter_score() returns a score below the minimum threshold, this exception is raised during pre_save to short-circuit persistence. The save() method catches it and returns without error.

Source code in src/popoto/exceptions.py
class SkipSaveException(ModelException):
    """Raised by WriteFilterMixin to silently abort a save operation.

    When a model's compute_filter_score() returns a score below the
    minimum threshold, this exception is raised during pre_save to
    short-circuit persistence. The save() method catches it and returns
    without error.
    """

    pass

NeverRecordException

Bases: SkipSaveException

Raised by NeverRecordMixin when content must never be stored (#561).

Subclasses SkipSaveException on purpose: every existing handler that already swallows a filtered save keeps working unchanged -- including Model.save()'s own catch and the broad except Exception in SubconsciousMemory.extract_memories() -- while callers that need to tell a privacy drop apart from a salience skip can catch this subclass.

The message carries only a reason code and detector name. It must never quote the matched text, an offset, or a length: exception messages reach plaintext log files (MemoryService._record_failure writes f"{type(exc).__name__}: {exc}" to disk), so a quoted match would leak through a side channel the tombstone design closed.

Source code in src/popoto/exceptions.py
class NeverRecordException(SkipSaveException):
    """Raised by NeverRecordMixin when content must never be stored (#561).

    Subclasses SkipSaveException on purpose: every existing handler that
    already swallows a filtered save keeps working unchanged -- including
    ``Model.save()``'s own catch and the broad ``except Exception`` in
    ``SubconsciousMemory.extract_memories()`` -- while callers that need to
    tell a privacy drop apart from a salience skip can catch this subclass.

    The message carries only a reason code and detector name. It must never
    quote the matched text, an offset, or a length: exception messages reach
    plaintext log files (``MemoryService._record_failure`` writes
    ``f"{type(exc).__name__}: {exc}"`` to disk), so a quoted match would
    leak through a side channel the tombstone design closed.
    """

    pass