Skip to content

← Config, Logs and Untrusted Input step 4 of 16

Medium End-to-End

Read Settings from Environment Variables

Your service reads five settings from environment variables when it starts. Every environment variable is a string, so "9000" has to become an int and "yes" has to become True. Do that once, in one function that returns a typed Settings record. When several variables are wrong, report them all at once, so the person deploying the service sees every mistake on the first try.

load_settings({"SERVICE_NAME": "billing", "PORT": "9000", "DEBUG": "yes"})
# Settings(service_name='billing', port=9000, debug=True,
#          environment='dev', timeout_seconds=5.0)

What to write

Fill in load_settings(env: Mapping[str, str]) -> Settings. Read only env, not os.environ, and do not change it. Taking the mapping as an argument lets the tests pass in any environment they like. Keep Settings, FieldError and solve as they are, and add helpers if you like.

Field Variable Conversion If the variable is absent
service_name SERVICE_NAME keep the string as it is error, it is required
port PORT int(raw) 8080
debug DEBUG the words below False
environment ENVIRONMENT the names below "dev"
timeout_seconds TIMEOUT_SECONDS float(raw) 5.0
  • A default applies only when the variable is absent. A variable set to the empty string is present. So SERVICE_NAME="" gives an empty name, and PORT="" is an error, because int("") raises ValueError.
  • Keep SERVICE_NAME exactly as given, and add no range or non-empty checks. int and float already accept a sign and spaces around the number.
  • For DEBUG, strip spaces and lowercase the text. Then "1", "true", "yes" and "on" mean True, and "0", "false", "no" and "off" mean False. Anything else, including the empty string, is an error. Do not call bool(raw), because every non-empty string is truthy, "false" included.
  • For ENVIRONMENT, strip spaces and lowercase the text, then accept only "dev", "staging" or "prod". The result must have the type Environment, and mypy checks that. One way is to loop over _ENVIRONMENTS and return the item that matches.
  • Ignore every other variable, such as PATH.

Report every error

Check the variables in this order: SERVICE_NAME, PORT, DEBUG, ENVIRONMENT, TIMEOUT_SECONDS. When one fails, create a FieldError(name, detail), add it to a list and go on to the next. Make the detail say what format you accept, such as "expected an integer". After the last check, if the list is not empty, raise:

ExceptionGroup("invalid settings", errors)

Return Settings only when there were no errors.

Each FieldError records why it happened in __cause__:

  • For a failed conversion, the cause is the ValueError that the conversion raised. int and float raise it for you. Make your DEBUG and ENVIRONMENT parsers raise ValueError too.
  • A missing SERVICE_NAME has no conversion error, so its cause stays None.

raise FieldError(...) from exc would set the cause, but you are collecting errors, not raising them yet. Set it yourself:

error = FieldError(name, detail)
error.__cause__ = exc
errors.append(error)

What the driver reports

Keep solve as it is. On success it returns "ok": True, the five fields, and "frozen", which is true when the object has no __dict__. That holds because Settings uses slots=True, so keep frozen=True, slots=True on it.

On failure it returns "ok": False, a "count" of the errors in the group, and two lists. "fields" holds the variable names, and "causes" holds the type name of each __cause__, so a missing variable adds "NoneType". The driver sorts each list on its own, so the same position in the two lists need not belong to the same error. With SERVICE_NAME="billing", PORT="80x" and TIMEOUT_SECONDS="soon", it returns:

{
    "ok": False,
    "fields": ["PORT", "TIMEOUT_SECONDS"],
    "causes": ["ValueError", "ValueError"],
    "count": 2,
}

Going further

The chained ValueError repeats the bad input, as in invalid literal for int() with base 10: '80x'. If a setting ever holds a secret, logging the whole exception group logs the secret too.