popoto.exceptions¶
popoto.exceptions
¶
Custom exceptions for the Popoto Redis ORM library.
ModelException
¶
QueryException
¶
PublisherException
¶
SubscriberException
¶
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
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:
-
A corrupt KeyField. A defaulted or skipped key is a wrong identity.
save()'s two safety nets (KeyMutationErrorand 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 withredis-cli HGETALL. -
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, sosave(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
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
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
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
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.