---
title: Error reference
description: Every ConfigError variant in rtb-config — its diagnostic code, the exact conditions that produce it, and what to do about it.
---
# Error reference
`ConfigError` is the only error type the crate returns. It derives
`thiserror::Error` and `miette::Diagnostic`, so every variant carries a diagnostic
code in the `rtb::config::*` namespace and prints with `miette`'s formatting when
your `main` returns `miette::Result`.
The enum is `#[non_exhaustive]`. A `match` over it needs a `_` arm, and a future
release can add a variant without that being a breaking change.
## Which variant means what
| `Parse(String)` | `rtb::config::parse` | `build`, `reload` |
| `Io { path, source }` | `rtb::config::io` | `build`, `reload` |
| `Watch(String)` | `rtb::config::watch` | `watch_files` |
| `Write(String)` | `rtb::config::write` | `write` |
| `Schema(String)` | `rtb::config::schema` | Nothing in the crate today — see below |
`Watch`, `Write` and `Schema` exist unconditionally even though the code that
constructs them is behind a Cargo feature. That is deliberate: a downstream `match`
does not have to be `#[cfg]`-gated to compile with the features off.
## `Parse` — the merged configuration did not fit the struct
```text
configuration error: invalid type: found string "not-a-number", expected u16 for key "PORT" in `MYTOOL_` environment variable(s)
```
The catch-all for anything the merge-and-deserialise step rejects. It carries the
underlying message, which names the offending key and the layer it came from.
It covers more ground than the name suggests:
- **Malformed YAML** in a user file.
- **A type mismatch** — `port: "eighty"` against a `u16`, whether it came from a file
or an environment variable.
- **A missing required field** — any field without `#[serde(default)]` that no layer
supplied.
- **An unknown field**, but only if your struct carries `#[serde(deny_unknown_fields)]`.
Without it, unknown keys are ignored.
- **A file that exists but cannot be read** — a permissions failure arrives here as
`Parse`, wrapping the OS message, *not* as `Io`.
The diagnostic help text is `check your config file and environment variables
against the schema`.
## `Io` — a config path exists but is not a regular file
```text
could not read config file /etc/mytool: config path is not a regular file
```
Narrower than its name suggests. The crate returns `Io` in exactly one case: a path
registered with `user_file` exists on disk and is **not a regular file** — a
directory being the usual way to get here, typically by passing a config *directory*
where a file was wanted.
It carries the offending `path`, which is the useful part when several files are
registered, and an `std::io::Error` as its source.
A path that does not exist at all is not an error of any kind. A path that exists and
cannot be opened is a [`Parse`](#parse-the-merged-configuration-did-not-fit-the-struct)
error.
## `Watch` — the file watcher could not start
```text
config watcher error: no user files registered
```
Only ever returned by `watch_files`, and only at start-up. Three causes:
| `no user files registered` | The `Config` was built without any `user_file` path — including anything built by `with_value` or `default()` | Register the file you want watched with `ConfigBuilder::user_file` |
| `watch <path>: No path was found. about ["<path>"]` | The registered path does not exist yet | Create the file before calling `watch_files`; the watcher cannot wait for a path to appear |
| `debouncer: …` / `spawn watcher thread: …` | The OS refused a watch handle or a thread | Check inotify limits (`fs.inotify.max_user_watches` on Linux) and process limits |
Once the watcher is running it never returns an error. A reload that fails after a
bad save is swallowed on purpose so one broken write does not take the watcher down
— but it also means a watcher that has stopped noticing changes reports nothing. See
[limitations](../explanation/limitations.md#which-file-changes-does-the-watcher-actually-see).
## `Write` — writing the config file failed
```text
config write error: create parent /tmp/blocker: File exists (os error 17)
```
Returned by `Config::write` under the `mutable` feature, for both halves of the job:
- **Serialisation failed** — the message is prefixed with the format, `yaml:`,
`toml:` or `json:`. TOML is the one that realistically fails, because it cannot
represent every structure serde can.
- **The filesystem refused** — creating the parent directory or writing the file.
The message carries the path.
## `Schema` — reserved for schema validation
Nothing in `rtb-config` constructs this variant today. It exists for consumers that
validate a candidate value against `Config::schema` before writing it — a
`config set` command checking an edit before it lands — so that the failure has a
home in the same error type rather than needing a second one.
If you are looking for the error that a bad value in a config file produces, that is
[`Parse`](#parse-the-merged-configuration-did-not-fit-the-struct).
## How to handle these in a tool
`ConfigError` implements `miette::Diagnostic`, so the shortest useful handling is to
let it propagate:
```rust
fn main() -> miette::Result<()> {
let cfg = rtb_config::Config::<MyConfig>::builder()
.embedded_default(include_str!("../defaults.yaml"))
.user_file("/etc/mytool/config.yaml")
.env_prefixed("MYTOOL_")
.build()?;
// ...
Ok(())
}
```
That prints the message, the diagnostic code and the help line. Match on the enum
only when you want to *act* differently — for instance treating `Io` as "the user
pointed at a directory, re-prompt" while letting `Parse` fail the run.
## Next
- [API reference](api.md) — which method returns which error
- [Limitations](../explanation/limitations.md) — the failures that are silent rather than errors