Error Handling and Recovery
This guide describes the error and persistence contracts of Engrava v0.7. It is for application code that must decide whether to fix an input, retry a remote call, reconcile a partial write, or replace a store.
The central rule is: classify the failure and establish what committed before retrying. An exception does not universally mean “nothing was written,” and Engrava does not make every write idempotent.
Decision table
| Signal | State after the error | Action | Retry unchanged? |
|---|---|---|---|
ConfigError, validation ValueError, invalid filter/recency/cycle, dimension or prefix mismatch |
Rejected at a validation boundary in the usual path | Correct the configuration or arguments | No |
ThoughtNotFoundError, ActionNotFoundError, SourceThoughtNotFoundError, ReferentialIntegrityError |
Required data is absent | Refresh identifiers or create the missing parent deliberately | No |
DuplicateEdgeError |
The requested directed, typed relationship already exists | Treat the write as idempotent success or choose a different direction/type | No |
InvalidTransitionError, ReadOnlyViolationError |
Requested operation is not allowed in the current state/view | Change the requested transition or use a writable capability | No |
StaleDataError |
The guarded update matched no row — another guarded write bumped the row’s revision in between, or deleted the row; nothing of the rejected update was written |
Re-read, recompute the intended change, then issue a new update — and handle the row being gone | No, not without re-reading |
Remote embedding timeout, network error, 408, 409, 425, 429, or selected 5xx |
Depends on the operation; a single thought/update may already be committed | Let the provider exhaust its bounded retry policy, then reconcile by thought ID | Not the whole write blindly |
SQLite OperationalError containing locked or busy |
The operation did not complete successfully; reconcile durable state when the write boundary is ambiguous | Reduce writer contention or increase busy_timeout; retry only an operation known to converge |
Only with operation-specific proof |
WriteContentionError |
The call could not get SQLite’s write lock; the failed attempt wrote nothing | Retry the call; if it keeps recurring, remove the contention (see SQLite lock contention below) | Yes, the call itself |
WriteLockTimeoutError |
A task could not get the store’s in-process write lock within write_lock_acquire_timeout_seconds; the guarded write did not start |
Find the cause first: a task spawned and awaited from inside another task’s open suspend_auto_commit() window (drive writes from the one task that opened it), or a bound too small for a slow embedding provider (raise it) |
Not until the cause is fixed |
DedupLockReentryError |
A task re-entered the dedup window it already holds and was refused instead of blocking on itself | Fix the caller: an update_thought() override reached from upsert_by_hash()’s hit branch must not call back into a dedup entry point |
No |
Rising fts_match_failure_count |
Search retried with sanitized FTS syntax; the vector and other hybrid arms can still contribute | Inspect warning logs and offending queries | Engrava already retries the FTS arm once |
Rising vector_arm_degradation_count |
The vector arm returned no results for an all-zero or non-finite query vector of the store’s declared length (or an empty vector, but only when the store declares no dimension) — a wrong-length vector otherwise, including an empty one against a declared dimension, raises VectorDimensionMismatchError instead |
Fix query embedding generation | No; the same vector degrades again |
EmbeddingGenerationError from single-item create/update |
If this call owns its outermost transaction, the thought/update is committed; embedding is missing (create), or, only if the row already had one, left in place and now stale (update). Nested inside the caller’s own suspend_auto_commit(), nothing is durable yet — the outermost window’s exit decides, and a rollback reverts to whatever existed when that window opened, an earlier write in the same window included |
If this call owned its transaction: look up the thought and repair its embedding, do not recreate it. If it was nested and the outer window rolled back, look the thought up by id first and branch on what you find — do not assume based on which call you issued | Only when durable: retry embedding, not creation/update |
JournalIntegrityError, ExtensionMigrationError, SchemaVersionError, CoreMigrationError |
Store opening or migration was rejected; CoreMigrationError leaves the schema version at the last fully applied step |
Stop writes, preserve the files, diagnose or restore. For SchemaVersionError, read reason: newer_than_head needs a newer Engrava; for the two sub-floor reasons, do not delete or re-initialise the file (see below) |
No |
ConnectionQuarantinedError |
The store instance is terminally unusable | Close it, create a new store over a fresh connection, then reconcile durable state | Never on the same store |
Caller, data, and configuration errors
These failures require a changed request, not backoff:
ConfigErrormeans configuration loading, provider resolution, or a bounded configuration value failed validation.ValueErroris also used by some public write boundaries, including duplicate thought IDs and invalid metadata/provenance. Pydantic may raise its own validation error while constructing records.InvalidFilterErrorandInvalidFilterPathErrorreject an invalid compiled metadata or visibility filter before query execution.RecencyModeConflictError,InvalidRecencyArgumentError, andCycleProviderErroridentify an invalid recency request or provider result.EmbeddingModelMismatchError,EmbeddingQueryPrefixMismatchError, andVectorDimensionMismatchErrorprotect the corpus from incompatible vectors. Restore the matching model/prefix/dimension or deliberately re-embed the corpus; repeating the same call cannot repair the mismatch.EmbeddingProviderContractErroridentifies a custom embedding provider that does not expose a requiredEmbeddingProviderProtocolmember — today a publicdimension. It names the provider class and the member; retrying cannot help, add the property. A provider that has the property and fails inside it does not raise this: its own exception propagates unchanged, and is classified by whatever raised it.ThoughtNotFoundError,ActionNotFoundError,SourceThoughtNotFoundError, andReferentialIntegrityErroridentify a missing target or parent.DuplicateEdgeErroridentifies an existing(from, to, type)relationship; callers can safely use it as the idempotent-create signal without parsing SQLite error text.InvalidTransitionErrorrejects an illegal lifecycle/action transition, andReadOnlyViolationErrorrejects a write throughReadOnlyEngrava.StaleDataErrormeans a guarded update matched no row: another guarded write bumped the row’srevisionbetween your read and your write, or the row was deleted in that window. The error does not say which, and nothing of the rejected update was written. It is recoverable only through a new read-modify-write cycle: re-read the record, recompute the change, and do not replay stalechangeswithout checking the newer record. The engine bumpsrevisionon every guarded write, so this fires whatever field the other write touched. Between ordinary tasks sharing one store, the write lock (Concurrency) already serialises guarded writes, so it almost never fires there; the shapes it does catch are a write from a second store on the same file and a same-task nested write reached through a caller-owned hook — see Concurrency. Its absence is not evidence that no one else wrote.WriteLockTimeoutErrorandDedupLockReentryErrorare raised instead of a hang. The first means a task waited longer thanwrite_lock_acquire_timeout_secondsfor the store’s write lock; the exception alone does not say whether a task was spawned and awaited from inside another task’s opensuspend_auto_commit()window (a caller bug: issue the window’s writes from the one task that opened it) or the bound is too small for a slow embedding provider or large batch (raise it). The second means the same task tried to re-enter the dedup window it already holds — typically anupdate_thought()override reached fromupsert_by_hash()’s hit branch that calls back intocreate_thought(deduplicate=True),get_or_create(),upsert_by_hash()orbulk_store(deduplicate=True). Repeating the call unchanged cannot help; see Concurrency.DerivedRecordErrormeans a producer violated a derivation gate or collided with an unrelated identity. The source thought is already durable on the automatic post-store path; some earlier derived children may also be durable.
JournalIntegrityError and ExtensionMigrationError are operator failures,
not caller mistakes. Do not keep opening the same files in a write-capable
process. Preserve the database and WAL files, inspect the reported sequence or
migration, and follow Backup & Recovery.
SchemaVersionError and CoreMigrationError are the same kind of failure,
raised by ensure_schema() and so by from_config(),
EngravaManager.get_store(), and engrava migrate. SchemaVersionError
carries current_version and a reason:
newer_than_head: the file was written by a newer Engrava.engrava migratecannot help; upgrade Engrava before opening it.populated_sub_floorandstale_shape_sub_floor: the file is stamped below the bootstrap floor and this build cannot tell which older version produced it, so it refuses instead of stamping it current. Do not delete or re-initialise it — preserve it and follow Backup & Recovery.
CoreMigrationError means a migration step returned without leaving the
structure it targets (a column, table, index, foreign key, or trigger) in
place. The version is not stamped past the last fully applied step, so the
next ensure_schema() retries the remaining steps rather than treating a
half-migrated file as current; repeating the open without changing anything
is not a fix. Replace the database from a backup, or use a build that
matches its schema history.
The API Reference lists the documented exceptions.
Remote embedding failures
Provider behavior is deliberately provider-specific:
OpenAICompatibleProviderretries transport timeouts/network errors and HTTP408,409,425,429,500,502,503, and504. It makes at mostmax_attemptsattempts (default3), waitingbase_retry_delay_s * attempt_numberbefore each next attempt (default waits: 1 second, then 2 seconds). Other non-success statuses, such as400,401,403, and404, fail immediately.OllamaProviderandHuggingFaceProviderdo not add an Engrava retry loop. Their dependency/provider exceptions propagate.- Local, callback, and third-party providers define their own failure behavior.
Once provider attempts are exhausted, auto-embedding on the thought a caller
explicitly created or updated never swallows the error: with
require_embedding=true, Engrava wraps a failure of the provider’s embed call
in EmbeddingGenerationError; otherwise the provider’s original exception is
re-raised (see Embeddings
for what that covers). Strict mode changes the exception type, not the
single-item commit boundary. A derived child’s own embedding failure is a separate path — under the default
on_error="log" derivation-gate policy, it is logged and derivation
continues, so the call that created or updated the source does not see it —
see Derived records below.
For a search that only failed while generating its query vector, retrying the search after a transient provider recovery does not replay a graph write. For a write with auto-embedding, inspect the persistence boundary below before doing anything else. See Embeddings for provider setup and retry controls.
Persistence boundaries
Single thought creation
Outside suspend_auto_commit(), create_thought() (and therefore remember())
commits the thought and its journal entry before auto-embedding, TTL cleanup,
on_store, and automatic derivation run. A later failure leaves the source
thought durable.
Consequences:
- An embedding-provider failure leaves a newly-created thought without an embedding. It remains available to direct/FTS retrieval but is absent from vector search.
- An
on_storeor derivation failure does not roll back the source. Depending on where derivation failed, earlier derived children may already be committed. - Retrying
create_thought()with the same ID raisesValueError. Retryingremember()with the defaultdeduplicate=Falsepersists a new randomly identified row; withdeduplicate=True, a matching row is reused instead and its confirmation count is incremented. Neither the default call nor the deduplicated one is a repair for a failed embedding.
Use strict embedding mode when callers need the typed thought ID for repair:
from engrava import EmbeddingGenerationError
async def create_with_partial_state(store, thought):
try:
return await store.create_thought(thought), True
except EmbeddingGenerationError as exc:
persisted = await store.get_thought(exc.thought_id)
if persisted is None:
raise RuntimeError("embedding failure did not leave the expected thought") from exc
return persisted, False
The False result means “the thought exists but embedding repair is required,”
not “creation failed atomically.”
If the application retains the provider, it can rebuild the same payload and role-aware dispatch used by auto-embedding, then upsert the missing vector:
from engrava import RoleAwareEmbeddingProvider, ThoughtNotFoundError
async def repair_embedding(store, provider, thought_id):
thought = await store.get_thought(thought_id)
if thought is None:
raise ThoughtNotFoundError(thought_id)
essence = thought.essence.strip()
content = thought.content.strip()
payload = thought.content if content.startswith(essence) else f"{thought.essence}\n{thought.content}"
if isinstance(provider, RoleAwareEmbeddingProvider):
vector = await provider.embed_document(payload)
else:
vector = await provider.embed(payload)
return await store.store_embedding(
thought_id,
vector,
model_name=provider.model_name,
)
Use the same provider/model and role prefixes that built the corpus. The store’s model lock rejects an incompatible model or dimension.
Thought updates
update_thought() commits the updated row and journal entry before
re-embedding when essence or content changed, provided this call owns its
outermost transaction. Nested inside the caller’s own suspend_auto_commit()
window, nothing is durable yet on any path — the outermost window’s exit
decides, rolling back an uncaught failure and everything it inserted. If the
update does commit and query generation then fails, the row contains the new
text but:
- a thought that had no embedding remains unembedded, and stays unfindable by vector search;
- a thought that had an embedding retains the old, now stale vector, and stays findable by vector search against that outdated content;
- dependent REFLECTION centroids may not yet have been rebound.
Re-read the thought and repair/recompute enrichment. Do not replay the update
unchanged and assume that doing so re-embeds: an update that no longer changes
essence or content does not enter the re-embedding branch.
Bulk ingest
bulk_store() is different. Source rows, journal entries, and batch-generated
embeddings run inside one suspend_auto_commit() transaction. Called on its
own, any exception during that phase, including a provider exception or a
task cancellation, rolls the batch back, with the exceptions listed under
Transaction context behavior below. The
require_embedding option controls whether a failure of the provider’s embed
call is wrapped as EmbeddingGenerationError; it does not change the rollback
(see Embeddings
for what that covers).
Nested inside a caller’s own suspend_auto_commit() window, the batch shares
that outer transaction: a row error still aborts the batch’s own inserts, but
only the outermost window’s exit decides commit or rollback. If the caller
catches the propagated exception and the outer window then exits cleanly,
the batch’s successful prefix commits along with the rest of the outer
block’s work instead of being rolled back.
Automatic derivation runs only after the batch commits. A derivation failure can
therefore leave the complete source batch durable and zero or more derived
children durable. Reconcile sources by ID/content and use derive_existing() to
complete deterministic derivation rather than blindly replaying bulk_store().
Derived records
The source is durable before automatic derivation. Outside a caller-held
transaction, each derived child’s row is committed as its own unit before
its embedding and DERIVED_FROM edge are completed, and a failed child
undoes only its own failing step — the row and its journal entry, the edge
and its journal entry, or an embedding attempt — never an earlier child’s
work. Re-running derive_existing(source_id) reuses deterministic children
and edges and fills missing enrichment, provided the producer obeys the
deterministic content contract.
Inside a caller’s own suspend_auto_commit() window, derive_existing()
does not defer — the source is already durable — but its children join that
transaction instead of committing per-child: nothing is durable until the
window’s own final commit, and a failure that escapes the window uncaught
rolls back everything the window holds, earlier children included — unless
the connection is already quarantined, in which case the window attempts
no rollback of its own (see
Terminal connection quarantine below).
A raw BEGIN differs again: a child’s own successful step still commits
the shared transaction as soon as that step succeeds.
Transaction context behavior
suspend_auto_commit() groups writes on one store connection. A normal exit
of the outermost call commits once; an exception that escapes that
outermost block — including task cancellation — causes a rollback of the
transaction and is re-raised, unless the connection is already quarantined,
in which case no rollback is attempted (see
Terminal connection quarantine). A nested
call’s own exit neither commits nor rolls back anything by itself; only the
outermost one decides, so an exception raised inside a nested window but
caught by your own code before it reaches the outermost block still lets
that block commit normally.
async def store_atomically(store, first, second, link):
async with store.suspend_auto_commit():
await store.create_thought(first)
await store.create_thought(second)
await store.create_edge(link)
Operational rules:
- Let an error escape the
async withblock. Catching it inside and then leaving normally tells the context manager to commit. suspend_auto_commit()holds a task-reentrant lock for its whole duration: a different task’s write on the same store instance now waits for the window to close instead of joining its transaction — bounded pastwrite_lock_acquire_timeout_seconds, where it raisesWriteLockTimeoutError— and nesting on the same task is supported (only the outermost call commits or rolls back). Drive the window from one task at a time regardless: a second task’s write is safe from corruption, but it still simply waits, so interleaving unrelated writes through a long-running window just serialises them for no benefit. See Concurrency for the full contract.- Automatic on-store derivation is skipped inside a caller-held transaction.
After commit, invoke
derive_existing()explicitly for sources that need it. - The rollback branch catches
BaseExceptionat every nesting level, soasyncio.CancelledErroris caught the same way an ordinary exception is; it rolls back the transaction only when it escapes the outermost block uncaught, exactly like any other error. One exception is a cancellation that lands during the outermost window’s owncommit(): the commit runs after the body has already returned, outside the rollback branch, so it may or may not have completed by the time the cancellation propagates. Treat the outcome as indeterminate: reconcile the affected IDs from a new store before repeating anything, and do not let a later commit decide the fate of an indeterminate transaction.
For the supported writer topology and task boundaries, see Concurrency.
SQLite lock contention
SQLite permits many readers but only one writer. Stores opened through
SqliteEngravaCore.from_config() or EngravaManager set
PRAGMA busy_timeout=5000, so a competing connection waits for up to five
seconds before surfacing database is locked. A manually supplied connection
keeps its own pragma settings.
create_thought(deduplicate=True), get_or_create(), upsert_by_hash(),
bulk_store(deduplicate=True) and the revision-guarded updates
(update_thought, restore_thought, update_edge, update_action) convert
lock contention into WriteContentionError, carrying
operation and attempts. The failed attempt wrote nothing, so retrying
that call outright is safe. Other write paths can still surface the raw
OperationalError from the table above.
Prefer removing contention over adding a generic retry loop:
- Keep write transactions short.
- Serialize writes per store/process.
- Partition independent writers into separate database files with
EngravaManager. - Increase
busy_timeoutonly when the added tail latency is acceptable.
busy_timeout is a latency knob, not a correctness one. It decides how long a
connection waits for the file lock — except a content-changing update_thought(),
which can fail immediately without waiting; it does nothing about two stores interleaving
inside one of engrava’s read-modify-write operations, which is why only one store
may write a given file
(Concurrency).
If an operation is known to be read-only and repeatable, bounded retry is reasonable. This example deliberately retries journal verification, not an arbitrary write:
import asyncio
import sqlite3
async def verify_journal_with_lock_retry(store, attempts=3):
for attempt in range(1, attempts + 1):
try:
return await store.verify_journal()
except sqlite3.OperationalError as exc:
locked = "locked" in str(exc).lower() or "busy" in str(exc).lower()
if not locked or attempt == attempts:
raise
await asyncio.sleep(0.1 * attempt)
raise AssertionError("unreachable")
Do not wrap every Engrava method in this helper. After an ambiguous write error, query the durable state from a healthy connection and choose a method whose repetition is known to converge.
Self-healing search and counters
Search has two intentional degradation paths:
- If the primary FTS5
MATCHraisesOperationalError, Engrava incrementsfts_match_failure_count, logs a warning, sanitizes the original query into bare mode, and retries once. If that fallback also fails, the FTS arm returns no results; hybrid search can still use its other active arms. - An all-zero,
NaN, or infinite query vector of the store’s declared length has no cosine direction.search_similar()incrementsvector_arm_degradation_countand returns[]. A wrong-length vector is not degraded: the dimension check runs first and raisesVectorDimensionMismatchError— including for an empty vector, which is a length-0 mismatch whenever the store declares a dimension. Only when the store declares no dimension does the dimension check never fire, so an empty vector falls through to the same degenerate-vector handling as the other two shapes. See API Reference for the exact ordering.
The counters are cumulative per store instance and reset when a new store is constructed. Alert on growth/deltas, not merely on a non-zero lifetime value:
async def recall_with_degradation_flags(store, query):
fts_before = store.fts_match_failure_count
vector_before = store.vector_arm_degradation_count
result = await store.recall(query)
return (
result,
store.fts_match_failure_count > fts_before,
store.vector_arm_degradation_count > vector_before,
)
An unavailable FTS5 index returns an empty FTS arm. Failure to load the optional
sqlite-vec extension at store setup logs a warning and falls back to exhaustive
NumPy search; it does not increment vector_arm_degradation_count. Runtime
backend failures outside the two cases above are not covered by a blanket
self-healing guarantee.
See Search for the result-level signals.
Terminal connection quarantine
ConnectionQuarantinedError means Engrava could not prove that a rollback or
unwind it depends on completed, or that the connection’s last operation ever
reported. The old connection may have an indeterminate open transaction, so
the store revokes all new operations and replaces its internal connection
with a failing proxy. This state never clears. Causes include:
- the savepoint unwind of a guarded write (
update_thought,restore_thought,update_edge,update_action, a derived child’s row insert or edge insert) failed; - the savepoint unwind of a
delete_thoughtfailed, including a delete made through the TTL delete strategy or hygiene garbage collection — an ordinary error such as a trigger veto can cause this, not only a cancellation; close()gave up waiting on the physical close afterclose_timeout_seconds— see Deployment.
The call that triggered the quarantine can surface its original or
derivation error (or a cancellation that outranks it); a close() whose
bound expired raises ConnectionQuarantinedError itself, unless a
cancellation of that call is pending. Once quarantine is
installed, the next public operation that reaches the connection raises
ConnectionQuarantinedError — an operation that returns before touching the
connection is unaffected. A second close() is the exception: its outcome
depends on the state of the abandoned physical close and on whether that call
is itself cancelled: see
Deployment.
Close the old store, open a new one, and inspect durable state before deciding what to repeat:
from engrava import ConnectionQuarantinedError, SqliteEngravaCore
async def replace_and_reconcile(store, config_path, source_id):
try:
source = await store.get_thought(source_id)
except ConnectionQuarantinedError:
await store.close()
store = await SqliteEngravaCore.from_config(config_path)
source = await store.get_thought(source_id)
if source is not None:
await store.derive_existing(source.thought_id)
return store
The caller owns the returned replacement and should close it during normal shutdown.
Do not retry against the quarantined instance, including through its journal. Quarantine detaches the real connection and schedules its best-effort close even when the store was built with the manual constructor. Any external reference to that same connection must still be treated as revoked and discarded.
Idempotency and convergence
Use these contracts narrowly:
| Operation | Repetition contract |
|---|---|
invalidate_thought(id, same_valid_until) |
Idempotent; converges to the same valid-time boundary |
invalidate_edge(id, same_valid_until) |
Idempotent; converges to the same valid-time boundary |
derive_existing(source_id) |
Idempotent only under the deterministic producer contract; reuses children/edges and completes missing enrichment |
upsert_by_hash(same_desired_record) |
State-convergent on the mutable fields, but a first-call post-commit enrichment failure still needs explicit reconciliation/derivation |
store_embedding(thought_id, same_vector, same_model) with the default deterministic embedding ID |
Upserts the same vector, but refreshes embedding metadata such as created_at; convergent, not byte-identical |
create_thought() |
Not idempotent; the same ID raises and a new ID creates another row |
remember() |
Not idempotent; each call creates a fresh ID unless content deduplication is requested |
create_thought(deduplicate=True) / get_or_create() |
Prevent duplicate content rows, but each hit increments confirmation_count; not observationally idempotent |
bulk_store() |
Atomic through source+embedding commit when it owns that transaction outright — nested inside a caller’s own suspend_auto_commit(), a caught row error can still commit the batch’s successful prefix, while a caught embedding failure commits every row the batch inserted instead, since that failure only fires after every row is already in — and, either way, not safe to replay blindly after a post-commit derivation failure |
| Retrieval calls | Do not mutate graph content, but can buffer access-frequency events when access tracking is enabled |
When an API is not listed as idempotent, assume that retry requires an application-level operation key and a read-back/reconciliation step.
For the exhaustive package reference, see the OSS error-handling.md docs.