Errors¶
Every surface — CLI, REST, MCP, Python — reports failures with the same
structured errors. Error codes are the stable API; message text is not:
match on code (snake_case), read candidates / suggested_retry / hint
to self-correct, and never parse prose.
How errors are structured¶
Errors are staged along the pipeline, one code enum per stage:
| Stage | When it runs | Payload shape |
|---|---|---|
| load | reading the model file | text only today (no code) |
| validate | structural checks on the model | {severity, code, location, message} |
| compile | lowering the model to IR | {code, location, message, candidates?, hint?} |
| plan | compiling a metric query | {code, message, metrics?, candidates?, suggested_retry?} |
| execute | running SQL on a warehouse | {code, message, hint?} |
Optional fields are omitted when empty, so payloads stay small. location
says where to edit (metric 'revenue',
semantic_model[0].relationships[2].to); candidates lists valid
alternatives for a bad reference; suggested_retry / hint states the
rewrite that would succeed.
Per surface:
- CLI —
--format jsonprints the error struct as JSON on stderr;validate --format jsonprints{issues, compile_errors, warnings}on stdout. Exit codes:0success,1engine-rejected (structured error),2usage error. Known asymmetries: load errors and non-validatevalidation issues are text-only. - REST & MCP — the identical body, wrapped:
{"error": {…}}. MCP tool failures return it as text content withisError: true; malformed MCP tool arguments return a plain-text schema error instead. - Python (
dosi_engine) —ModelError/QueryError/ExecuteError, all with.codeand.to_dict(); the planner'ssuggested_retryarrives ashint.
{"error": {"code": "unknown_metric",
"message": "unknown metric \"revenu\"",
"metrics": ["revenu"],
"candidates": ["revenue", "order_count", "..."]}}
Where each code can occur¶
| Surface | validate issues | compile errors | plan errors | execute errors |
|---|---|---|---|---|
CLI validate |
✓ (issues) |
✓ (compile_errors) |
— | — |
CLI query / explain / list |
✓ (text, aborts) | ✓ | ✓ | query --execute only |
REST POST /v1/validate, MCP validate_model |
✓ (in body) | ✓ (in body) | — | — |
REST /v1/query/compile · /explain, MCP compile_sql · explain_query |
— (model loads at startup) | ✓ (lazy compile) | ✓ | — |
REST /v1/query/execute, MCP run_query |
— | ✓ | ✓ | ✓ |
Python Engine(...) |
ModelError("invalid_model") |
ModelError("compile_error") |
— | — |
Python compile_sql / query |
— | — | QueryError |
ExecuteError |
Server-only codes (bad_request, unauthorized, forbidden, busy,
internal, timeout) can accompany any REST/MCP call — see
Server-level codes.
Model validation issues¶
Structural checks (dosi validate, POST /v1/validate). Errors make the
model invalid; warnings don't — the model still compiles.
| Code | Severity | When | Fix |
|---|---|---|---|
unsupported_version |
error | version ≠ the OSI spec version this engine implements |
pin the version the message names |
duplicate_name |
error | two models, datasets, fields, metrics or relationships share a name | rename one |
empty_datasets |
error | a semantic model has no datasets | add at least one dataset |
empty_columns |
error | a relationship declares no column pairs | fill from_columns / to_columns |
column_count_mismatch |
error | from_columns and to_columns differ in length |
they pair positionally — make them equal |
unknown_dataset |
error | a relationship endpoint names an undeclared dataset | fix the from: / to: name |
unknown_column |
error | a relationship column is not a declared field of its dataset | declare the field or fix the column |
missing_sql_dialect |
error (metric) / warning (field) | no ANSI_SQL (or other SQL) dialect entry |
add a SQL dialect expression |
self_relationship |
warning | a relationship joins a dataset to itself | usually intentional; review |
relationship_target_not_unique |
warning | to_columns is not the target's primary/unique key — the join may fan out |
point the join at a unique key |
{"severity": "error", "code": "unknown_dataset",
"location": "semantic_model[0].relationships[0].to",
"message": "relationship \"orders_to_customers\" references unknown dataset \"customers\""}
Compile errors¶
Lowering a valid model to IR (compile_errors in the validate envelope; on
query surfaces they surface when the model first compiles). All carry
location; candidates / hint where noted.
| Code | When | Payload extras |
|---|---|---|
unparseable_expression |
expression is not parseable SQL | |
no_sql_dialect |
a field/metric in use has no SQL dialect entry | |
unknown_dataset |
SUM(no_such.amount) — qualifier is not a dataset; also (mis)used when a document has ≠ 1 semantic models |
candidates: dataset names |
unknown_field |
dataset.field where the dataset lacks that field |
candidates: that dataset's fields |
unknown_column |
bare column found in no dataset | hint: qualify as dataset.field |
ambiguous_column |
bare column exists in several datasets | candidates: qualified spellings |
not_an_aggregate |
metric expression doesn't aggregate | hint: wrap in SUM(...) |
nested_aggregate |
SUM(MAX(...)) |
|
bare_column_in_metric |
non-aggregated column (or literal-only aggregate) in a metric | hint: aggregate a dataset field |
cross_dataset_aggregate |
one aggregate mixes columns of two datasets | split the aggregate |
count_star_needs_dataset |
bare COUNT(*) in a multi-dataset model |
hint: count a key column |
unsupported_aggregate |
aggregate outside SUM/COUNT/COUNT DISTINCT/AVG/MIN/MAX | |
multi_column_count_distinct |
declared but currently never produced | |
window_in_metric |
window function in a metric expression | hint: precompute in the dataset |
subquery_in_metric |
subquery in a metric expression | |
measure_name_collision |
two different aggregates synthesize the same measure name | hint on some sites |
metric_reference_cycle |
D-DERIVE compose metrics reference each other | message shows the cycle path |
derive_expression_mismatch |
authored fallback expression ≠ the derive expansion | hint |
invalid_datus_extension |
malformed DATUS payload (bad v, unknown field ref, …) |
hint with a docs anchor |
unsupported_datus_ext_version |
payload v has a newer major / pre-1.0 |
hint names the supported major |
datus_ext_key_required |
requires: [...] names a key this engine doesn't implement |
|
not_implemented |
a declared shape outside the current derive/window scope | hint with a docs anchor |
Compile warnings¶
Non-fatal: the model compiled, but not everything took effect. Shape
{code, location, message}; CLI prints ! … on stderr and exits 0.
| Code | When |
|---|---|
ignored_vendor_extension |
basic (standard-OSI) mode ignored a DATUS extension |
datus_ext_version_ahead |
payload declares a newer minor; unknown keys were dropped (named in the message) |
datus_ext_key_newer_than_declared |
a key newer than the declared v was honored anyway |
compose_no_conformed_dimensions |
a compose metric's members share no dimension — no query can group it |
Query planning errors¶
Rejections of a metric query (dosi query, /v1/query/*, compile_sql /
run_query). All HTTP 400 except not_implemented → 501 and
internal → 500.
| Code | When | Payload extras |
|---|---|---|
empty_query |
no metrics requested | candidates: all metrics |
unknown_metric |
metric name not in the model | metrics, candidates |
unknown_dimension |
group-by item not a dimension; also a window metric's time axis missing from the group-by | candidates or suggested_retry |
ambiguous_dimension |
bare field exists in several datasets | candidates: qualified spellings |
grain_on_non_time_dimension |
:grain on a non-time field |
suggested_retry |
grain_too_fine |
requested grain finer than the stored time_granularity |
suggested_retry |
duplicate_output_name |
two group-by items produce one output column | suggested_retry |
unknown_order_key |
order key is not an output column | candidates: output columns |
unsupported_filter |
subquery/window in where, or a malformed time range |
|
aggregate_in_where |
aggregate in where (metric filters unsupported) |
|
no_join_path |
no dataset reaches the measure and all group-bys many→one | metrics |
ambiguous_join_path |
more than one relationship path — semantics would differ | candidates: the paths, suggested_retry |
fan_out_risk |
duplicate-sensitive aggregate across a fanning join | metrics, suggested_retry |
time_range_needs_dimension |
several time dimensions in scope; range target unclear | candidates |
no_primary_time_dimension |
metric_time used but no primary time dimension declared |
candidates, suggested_retry |
metric_time_conflict |
queried metrics disagree on their time axis | metrics, suggested_retry |
unconformed_dimension |
dimension outside a derived metric's conformed set | metrics, candidates, suggested_retry |
window_reset_too_fine |
window resets finer than the queried grain | suggested_retry |
dialect_unsupported_window_function |
target dialect lacks the needed window function | metrics, suggested_retry |
not_implemented |
legal per the model, outside current engine scope | metrics, suggested_retry |
internal |
engine invariant violation — retrying won't help; report a bug |
A typical self-correction loop: unknown_metric → pick from candidates →
retry; fan_out_risk → follow suggested_retry (group by reachable
dimensions, or restate with COUNT DISTINCT/MIN/MAX):
{"code": "fan_out_risk",
"message": "measure \"views_source_views_sum\" aggregates Sum over dataset \"views_source\", which has no many→one path to [\"bookings_source\"]; evaluating a duplicate-sensitive aggregate (SUM/AVG/COUNT) across that join would double-count rows",
"metrics": ["views_times_booking_value"],
"suggested_retry": "group by dimensions reachable from the measure's dataset, or restate the metric with a duplicate-insensitive aggregate (COUNT DISTINCT/MIN/MAX)"}
Execution errors¶
Running compiled SQL on a warehouse (query --execute,
/v1/query/execute, run_query). Shape {code, message, hint?}.
| Code | HTTP | When |
|---|---|---|
config |
400 | connection profile broken (bad URI, unopenable DuckDB file, missing driver option) |
connection |
502 | warehouse unreachable |
auth |
502 | credentials rejected |
sql_rejected |
502 | the warehouse rejected the SQL (missing table, type error) — message embeds the warehouse's own error |
driver |
502 | driver-level failure after connecting |
timeout |
504 | statement or server execute timeout |
Server-level codes¶
dosi-server adds transport-level codes in the same envelope,
{"error": {"code", "message"}}:
| Code | HTTP | When |
|---|---|---|
bad_request |
400 | malformed JSON body, unknown dialect |
unauthorized |
401 | missing/wrong bearer token |
forbidden |
403 | execute called with --disable-execute |
busy |
429 | all execution slots taken (Retry-After: 1) |
timeout |
504 | the server's own execute timeout |
internal |
500 | unexpected server failure |
Python exceptions¶
The dosi_engine wheel raises one exception hierarchy (OsiError base,
.code + .to_dict() on all):
| Exception | Codes | Notes |
|---|---|---|
ModelError |
io, parse, invalid_model, compile_error |
invalid_model carries issues; compile_error carries compile_errors plus the first error's candidates/hint |
QueryError |
every plan code above | suggested_retry arrives as hint |
ExecuteError |
every execute code above |
Compile warnings are raised as Python RuntimeWarnings at Engine(...)
construction — python -W error promotes them to failures.
SQL-generation errors (parse_error, unsupported_aggregate, …) are an
internal boundary between planner and renderer; they surface inside compile
or plan errors above, never as a user-level contract of their own.
Every code on this page is pinned by tests: the negative-case catalog in
fixtures/errors/ runs on every commit, and its actual payloads are
browsable on the test-status dashboard ("Error contract" section).