Skip to main content

Error taxonomy

Every error from the config stack is a ConfigError with one of seven normatively fixed reason values. The structure is identical across all bindings:

ConfigError {
path: string # dot-notation (database.host)
reason: ConfigErrorReason # enum
details: string # human-readable message
source_id?: string # yaml:/app-config.yaml, etc.
}

The full ConfigErrorReason enum

The normative source is config-spec/_meta/error_reasons.yaml.

missing

The path does not exist in the config and no default was passed to the getter.

config.get_string("nonexistent.path")
# ConfigError(
# path="nonexistent.path",
# reason="missing",
# details="Key 'nonexistent.path' not found in config and no default provided",
# )

How to handle: check the spelling of the path, add a default argument, or make sure the key exists in app-config.yaml.

type_mismatch

The value exists but is incompatible with the requested type.

# YAML: pool_size: "twenty"
config.get_int("database.pool_size")
# ConfigError(
# path="database.pool_size",
# reason="type_mismatch",
# details="Expected int, got string 'twenty' (does not match ^-?\\d+$)",
# )

How to handle: change the YAML value to one that matches the expected type. Interpolated env values are always strings — make sure the string representation matches the regex for the type:

  • int: ^-?\d+$
  • float: standard floating-point parser
  • bool: true|false|yes|no|1|0 (case-insensitive)

env_unresolved

The env variable in ${VAR} is not set and no default was specified.

# app-config.yaml:
database:
password: "${DB_PASSWORD}"

If DB_PASSWORD is unset:

ConfigError(
path="database.password",
reason="env_unresolved",
details="Environment variable DB_PASSWORD is not set and no default provided",
)

How to handle: set the env variable (export DB_PASSWORD=...), or — only for non-secret values — add a default to the YAML (${LOG_LEVEL:-INFO}). For secrets without a default, this is a safety net: a missing env variable raises an explicit error at startup.

validation_failed

A typed section read (get_section / getSection / GetSection) did not pass schema validation.

class DatabaseConfig(BaseModel):
host: str
password: str = Field(..., min_length=1)

# YAML: password: "" (empty string)
config.get_section("database", DatabaseConfig)
# ConfigError(
# path="database.password",
# reason="validation_failed",
# details="String should have at least 1 character",
# )

How to handle: fix the YAML value; before deploying, add a staging-time check that runs Config.load() plus get_section / getSection / GetSection for every section.

parse_error

The YAML file does not parse (a syntax error).

# Invalid YAML:
database:
host: "localhost
port: 5432
ConfigError(
path="",
reason="parse_error",
details="YAML parse error at line 3: unexpected end of stream",
source_id="yaml:/app/app-config.yaml",
)

How to handle: validate the YAML through yamllint or yq and fix the syntax. An IDE with a YAML linter shows errors immediately.

source_unavailable

A ConfigSource could not load its data.

# The file does not exist:
Config.load("non-existent.yaml")
# ConfigError(
# path="",
# reason="source_unavailable",
# details="File '/app/non-existent.yaml' does not exist",
# source_id="yaml:/app/non-existent.yaml",
# )

Other examples planned for Phase 2+ sources:

  • EtcdSource: connection timeout → source_unavailable.
  • VaultSource: auth failure → source_unavailable.
  • HttpSource: 5xx → source_unavailable.

How to handle: confirm the file exists / the service is reachable / the credentials are correct.

reload_rejected

A hot reload was not applied because validation failed on the new config version.

ConfigError(
path="database.pool_size",
reason="reload_rejected",
details="New config rejected: database.pool_size expected number, got string 'invalid'",
)

This is logged, not thrown — the reload is rolled back atomically, subscribers are not notified, and the application keeps running with the previous values. The diagnostic warning in the log is for the operator.

Hierarchy in each binding

from dagstack.config import ConfigError, ConfigErrorReason

try:
config = Config.load("app-config.yaml")
db_cfg = config.get_section("database", DatabaseConfig)
except ConfigError as exc:
match exc.reason:
case ConfigErrorReason.missing:
log.error(f"Config does not contain {exc.path}")
case ConfigErrorReason.env_unresolved:
log.error(f"Env variable for {exc.path} is not set")
case ConfigErrorReason.validation_failed:
log.error(f"Invalid config at {exc.path}: {exc.details}")
case _:
log.error(f"Config error: {exc}")
raise

Secret masking in error messages

Fields from the scrub list (api_key, *_token, *_password, *_secret, ...) are automatically masked inside details:

ConfigError(
path="database.password",
reason="validation_failed",
details="Password '[MASKED]' does not match required pattern", # the actual value is hidden
)

See Secrets.

See also