feat: enhance error handling and configuration validation
Some checks failed
CI / lint-and-test (push) Failing after 27s
Some checks failed
CI / lint-and-test (push) Failing after 27s
- Added a global exception handler to log unhandled exceptions and return a generic 500 JSON response without exposing details to the client. - Updated the configuration to validate the `DATABASE_URL` format, ensuring it starts with `sqlite://` or `postgresql://`, and log warnings for invalid formats. - Introduced safe parsing for numeric environment variables (`HTTP_PORT`, `INIT_DATA_MAX_AGE_SECONDS`) with defaults on invalid values, including logging warnings for out-of-range values. - Enhanced the duty schedule parser to enforce limits on the number of schedule rows and the length of full names and duty strings, raising appropriate errors when exceeded. - Updated internationalization messages to include generic error responses for import failures and parsing issues, improving user experience. - Added unit tests to verify the new error handling and configuration validation behaviors.
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
"""Load configuration from environment (e.g. .env via python-dotenv).
|
||||
|
||||
BOT_TOKEN is not validated on import; call require_bot_token() in the entry point
|
||||
when running the bot.
|
||||
when running the bot. Numeric env vars (HTTP_PORT, INIT_DATA_MAX_AGE_SECONDS) use
|
||||
safe parsing with defaults on invalid values.
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -16,6 +17,11 @@ from duty_teller.i18n.lang import normalize_lang
|
||||
|
||||
load_dotenv()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Valid port range for HTTP_PORT.
|
||||
HTTP_PORT_MIN, HTTP_PORT_MAX = 1, 65535
|
||||
|
||||
# Project root (parent of duty_teller package). Used for webapp path, etc.
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
@@ -55,6 +61,48 @@ def _normalize_log_level(raw: str) -> str:
|
||||
return "INFO"
|
||||
|
||||
|
||||
def _parse_int_env(
|
||||
name: str, default: int, min_val: int | None = None, max_val: int | None = None
|
||||
) -> int:
|
||||
"""Parse an integer from os.environ; use default on invalid or out-of-range. Log on fallback."""
|
||||
raw = os.getenv(name)
|
||||
if raw is None or raw == "":
|
||||
return default
|
||||
try:
|
||||
value = int(raw.strip())
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"Invalid %s=%r (expected integer); using default %s",
|
||||
name,
|
||||
raw,
|
||||
default,
|
||||
)
|
||||
return default
|
||||
if min_val is not None and value < min_val:
|
||||
logger.warning(
|
||||
"%s=%s is below minimum %s; using %s", name, value, min_val, min_val
|
||||
)
|
||||
return min_val
|
||||
if max_val is not None and value > max_val:
|
||||
logger.warning(
|
||||
"%s=%s is above maximum %s; using %s", name, value, max_val, max_val
|
||||
)
|
||||
return max_val
|
||||
return value
|
||||
|
||||
|
||||
def _validate_database_url(url: str) -> bool:
|
||||
"""Return True if URL looks like a supported SQLAlchemy URL (sqlite or postgres)."""
|
||||
if not url or not isinstance(url, str):
|
||||
return False
|
||||
u = url.strip().split("?", 1)[0].lower()
|
||||
return (
|
||||
u.startswith("sqlite://")
|
||||
or u.startswith("postgresql://")
|
||||
or u.startswith("postgres://")
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Settings:
|
||||
"""Injectable settings built from environment. Used in tests or when env is overridden."""
|
||||
@@ -105,20 +153,30 @@ class Settings:
|
||||
raw_host = (os.getenv("HTTP_HOST") or "127.0.0.1").strip()
|
||||
http_host = raw_host if raw_host else "127.0.0.1"
|
||||
bot_username = (os.getenv("BOT_USERNAME", "") or "").strip().lstrip("@").lower()
|
||||
database_url = os.getenv("DATABASE_URL", "sqlite:///data/duty_teller.db")
|
||||
if not _validate_database_url(database_url):
|
||||
logger.warning(
|
||||
"DATABASE_URL does not look like a supported URL (sqlite:// or postgresql://); "
|
||||
"DB connection may fail."
|
||||
)
|
||||
http_port = _parse_int_env(
|
||||
"HTTP_PORT", 8080, min_val=HTTP_PORT_MIN, max_val=HTTP_PORT_MAX
|
||||
)
|
||||
init_data_max_age = _parse_int_env("INIT_DATA_MAX_AGE_SECONDS", 0, min_val=0)
|
||||
return cls(
|
||||
bot_token=bot_token,
|
||||
database_url=os.getenv("DATABASE_URL", "sqlite:///data/duty_teller.db"),
|
||||
database_url=database_url,
|
||||
bot_username=bot_username,
|
||||
mini_app_base_url=os.getenv("MINI_APP_BASE_URL", "").rstrip("/"),
|
||||
http_host=http_host,
|
||||
http_port=int(os.getenv("HTTP_PORT", "8080")),
|
||||
http_port=http_port,
|
||||
allowed_usernames=allowed,
|
||||
admin_usernames=admin,
|
||||
allowed_phones=allowed_phones,
|
||||
admin_phones=admin_phones,
|
||||
mini_app_skip_auth=os.getenv("MINI_APP_SKIP_AUTH", "").strip()
|
||||
in ("1", "true", "yes"),
|
||||
init_data_max_age_seconds=int(os.getenv("INIT_DATA_MAX_AGE_SECONDS", "0")),
|
||||
init_data_max_age_seconds=init_data_max_age,
|
||||
cors_origins=cors,
|
||||
external_calendar_ics_url=os.getenv(
|
||||
"EXTERNAL_CALENDAR_ICS_URL", ""
|
||||
|
||||
Reference in New Issue
Block a user