Error Handling
retrocast-core owns one EngineError boundary. The CLI renders it and exits non-zero. PyO3 translates it into a built-in Python exception. The published package does not contain a second Python exception hierarchy.
Expected route-local failures are different: they become FailureRecord data and keep their planner rank.
Interface Boundaries
| Exception | Meaning |
|---|---|
ValueError |
Invalid SMILES, InChIKey, chemical level, or typed artifact data |
OSError |
An artifact path could not be read or written |
RuntimeError |
Adapter, schema, worker-pool, scoring, analysis, or another native failure |
try:
predictions = retrocast.ingest(raw, "aizynthfinder", task)
except (ValueError, OSError, RuntimeError) as error:
logger.exception("RetroCast ingest failed: %s", error)
raise
use retrocast_core::error::{EngineError, Result};
fn run() -> Result<()> {
let predictions = ingest(raw, adapter, &task, mode, limit, workers)?;
// ...
Ok(())
}
from retrocast.exceptions import AdapterError, ChemError, RetroCastException
try:
predictions = ingest_candidates(raw_payload, adapter, task)
except (AdapterError, ChemError) as error:
logger.error("%s: %s", error.code, error)
raise
Do not branch on Python exception prose. Rust code should match a specific EngineError variant only when it can recover meaningfully.
Failure Records
A malformed route entry can be counted without aborting the rest of the planner artifact:
{
"rank": 2,
"failure": {
"code": "adapter.schema_invalid",
"target_id": "target-1",
"context": {"adapter": "aizynthfinder"}
}
}
The distinction is:
- a route-local failure becomes a ranked candidate when processing can continue
- an invalid artifact envelope, unsafe path, unknown adapter, or impossible workflow request aborts the call
Inspect FailureRecord.code when route-level accounting matters:
failures = [
candidate["failure"]["code"]
for target_candidates in predictions.to_dict().values()
for candidate in target_candidates
if candidate.get("failure") is not None
]
let failures = predictions
.values()
.flatten()
.filter_map(|candidate| candidate.failure.as_ref())
.map(|failure| failure.code.as_str())
.collect::<Vec<_>>();
failures = [
candidate.failure.code
for target_candidates in predictions.values()
for candidate in target_candidates
if candidate.failure is not None
]
Stable Code Families
Serialized failure codes are the durable route-level contract. Human-readable messages may become clearer without changing the code.
| Family | Examples | Meaning |
|---|---|---|
input.* |
input.invalid |
External CLI or configuration input is invalid. |
security.* |
security.path_invalid, security.path_traversal |
A path or filename is unsafe. |
config.* |
config.invalid_value |
Configuration cannot be interpreted safely. |
chem.* |
chem.invalid_smiles, chem.runtime_error |
Chemistry parsing, identity, or RDKit failed. |
curation.* |
curation.route_embedding_query_invalid |
Valid data is invalid for the requested curation operation. |
schema.* |
schema.logic_error |
Structurally valid data is logically impossible. |
benchmark.* |
benchmark.validation_failed |
Benchmark construction or uniqueness failed. |
adapter.* |
adapter.schema_invalid, adapter.target_mismatch, adapter.cycle_detected |
Raw planner output failed an adapter contract. |
io.* |
io.not_found, io.decode_failed, io.invalid_artifact_shape |
Artifact reading, decoding, shape, or writing failed. |
serialization.* |
serialization.syntheseus_failed |
Conversion to an external format failed. |
workflow.* |
workflow.error |
Orchestration failed at a workflow boundary. |
dataset.* |
dataset.resolution_failed, dataset.download_failed, dataset.verification_failed |
Hosted data resolution or integrity checking failed. |
validity.* |
validity.unsupported_tier |
No validator implements the requested validity tier. |
Not every family is currently emitted by every workflow. Adding a new code is a schema decision; repurposing an existing code is a breaking change.
Adapter Policy
An invalid artifact envelope is fatal. An invalid route entry becomes a failed candidate when the adapter can continue to later entries.
Use adapter.target_mismatch for a valid route whose canonical root differs from the requested target. Use adapter.schema_invalid when the raw payload does not match the adapter's declared shape. Chemistry failures keep their chem.* identity.
Workflow Accounting
Candidate statistics aggregate failures by code:
{
"statistics": {
"failures_by_code": {
"adapter.schema_invalid": 2,
"chem.runtime_error": 1
}
}
}
Manifests persist those counts so an ingest run can be audited without reparsing the raw planner output.
Rust Rules
- Return
Result<T, EngineError>across core boundaries. - Prefer a specific enum variant to an unstructured string.
- Preserve source errors where the original cause matters.
- Convert an adapter error into
FailureRecordonly at the boundary that owns rank and target context. - Validate path components before joining managed artifact paths.
- Never panic on caller-controlled data.
CLI Policy
The CLI is the process boundary. It prints a diagnostic and exits non-zero for fatal failures. Route-local failures do not make ingest fail when they have been preserved as candidate data.
Tests
Test the EngineError variant or serialized failure code. Assert exact messages only when CLI copy is itself the contract.
Adversarial coverage should include malformed JSON, invalid chemistry, unsafe paths, zero workers, partial route corruption, deterministic concurrent execution, and Python exception translation.
See adapter errors for route-local examples.