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:
@@ -8,7 +8,7 @@ import duty_teller.config as config
|
||||
|
||||
from fastapi import Depends, FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import Response
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -42,6 +42,16 @@ def _is_valid_calendar_token(token: str) -> bool:
|
||||
app = FastAPI(title="Duty Teller API")
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
def global_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||
"""Log unhandled exceptions and return 500 without exposing details to the client."""
|
||||
log.exception("Unhandled exception: %s", exc)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"detail": "Internal server error"},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/health", summary="Health check")
|
||||
def health() -> dict:
|
||||
"""Return 200 when the app is up. Used by Docker HEALTHCHECK."""
|
||||
@@ -106,6 +116,18 @@ class NoCacheStaticMiddleware:
|
||||
app.add_middleware(NoCacheStaticMiddleware)
|
||||
|
||||
|
||||
# Allowed values for config.js to prevent script injection.
|
||||
_VALID_LANGS = frozenset({"en", "ru"})
|
||||
_VALID_LOG_LEVELS = frozenset({"debug", "info", "warning", "error"})
|
||||
|
||||
|
||||
def _safe_js_string(value: str, allowed: frozenset[str], default: str) -> str:
|
||||
"""Return value if it is in allowed set, else default. Prevents injection in config.js."""
|
||||
if value in allowed:
|
||||
return value
|
||||
return default
|
||||
|
||||
|
||||
@app.get(
|
||||
"/app/config.js",
|
||||
summary="Mini App config (language, log level)",
|
||||
@@ -115,8 +137,8 @@ app.add_middleware(NoCacheStaticMiddleware)
|
||||
)
|
||||
def app_config_js() -> Response:
|
||||
"""Return JS assigning window.__DT_LANG and window.__DT_LOG_LEVEL for the webapp. No caching."""
|
||||
lang = config.DEFAULT_LANGUAGE
|
||||
log_level = config.LOG_LEVEL_STR.lower()
|
||||
lang = _safe_js_string(config.DEFAULT_LANGUAGE, _VALID_LANGS, "en")
|
||||
log_level = _safe_js_string(config.LOG_LEVEL_STR.lower(), _VALID_LOG_LEVELS, "info")
|
||||
body = f'window.__DT_LANG = "{lang}";\nwindow.__DT_LOG_LEVEL = "{log_level}";'
|
||||
return Response(
|
||||
content=body,
|
||||
@@ -183,10 +205,10 @@ def get_team_calendar_ical(
|
||||
) -> Response:
|
||||
"""Return ICS calendar with all duties (event_type duty only). Token validates user."""
|
||||
if not _is_valid_calendar_token(token):
|
||||
return Response(status_code=404, content="Not found")
|
||||
return JSONResponse(status_code=404, content={"detail": "Not found"})
|
||||
user = get_user_by_calendar_token(session, token)
|
||||
if user is None:
|
||||
return Response(status_code=404, content="Not found")
|
||||
return JSONResponse(status_code=404, content={"detail": "Not found"})
|
||||
cache_key = ("team_ics",)
|
||||
ics_bytes, found = ics_calendar_cache.get(cache_key)
|
||||
if not found:
|
||||
@@ -224,10 +246,10 @@ def get_personal_calendar_ical(
|
||||
No Telegram auth; access is by secret token in the URL.
|
||||
"""
|
||||
if not _is_valid_calendar_token(token):
|
||||
return Response(status_code=404, content="Not found")
|
||||
return JSONResponse(status_code=404, content={"detail": "Not found"})
|
||||
user = get_user_by_calendar_token(session, token)
|
||||
if user is None:
|
||||
return Response(status_code=404, content="Not found")
|
||||
return JSONResponse(status_code=404, content={"detail": "Not found"})
|
||||
cache_key = ("personal_ics", user.id)
|
||||
ics_bytes, found = ics_calendar_cache.get(cache_key)
|
||||
if not found:
|
||||
|
||||
@@ -42,7 +42,12 @@ def _validate_duty_dates(from_date: str, to_date: str, lang: str) -> None:
|
||||
try:
|
||||
validate_date_range(from_date, to_date)
|
||||
except DateRangeValidationError as e:
|
||||
key = "dates.bad_format" if e.kind == "bad_format" else "dates.from_after_to"
|
||||
key_map = {
|
||||
"bad_format": "dates.bad_format",
|
||||
"from_after_to": "dates.from_after_to",
|
||||
"range_too_large": "dates.range_too_large",
|
||||
}
|
||||
key = key_map.get(e.kind, "dates.bad_format")
|
||||
raise HTTPException(status_code=400, detail=t(lang, key)) from e
|
||||
except ValueError as e:
|
||||
# Backward compatibility if something else raises ValueError.
|
||||
|
||||
Reference in New Issue
Block a user