[NX-101 Issue] Refactor error handling to use consistent API error format
Replaced all inline error messages with the standardized `api_error` helper for consistent error response formatting. This improves clarity, maintainability, and ensures uniform error structures across the application. Updated logging for collector failures to include error class and switched to warning level for target unreachable scenarios.
This commit is contained in:
@@ -348,6 +348,7 @@ Common error codes:
|
|||||||
- `not_found` (`404`)
|
- `not_found` (`404`)
|
||||||
- `conflict` (`409`)
|
- `conflict` (`409`)
|
||||||
- `validation_error` (`422`)
|
- `validation_error` (`422`)
|
||||||
|
- `target_unreachable` (`503`)
|
||||||
- `internal_error` (`500`)
|
- `internal_error` (`500`)
|
||||||
|
|
||||||
## `pg_stat_statements` Requirement
|
## `pg_stat_statements` Requirement
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
from app.core.deps import require_roles
|
from app.core.deps import require_roles
|
||||||
|
from app.core.errors import api_error
|
||||||
from app.models.models import EmailNotificationSettings, User
|
from app.models.models import EmailNotificationSettings, User
|
||||||
from app.schemas.admin_settings import EmailSettingsOut, EmailSettingsTestRequest, EmailSettingsUpdate
|
from app.schemas.admin_settings import EmailSettingsOut, EmailSettingsTestRequest, EmailSettingsUpdate
|
||||||
from app.services.audit import write_audit_log
|
from app.services.audit import write_audit_log
|
||||||
@@ -96,9 +97,9 @@ async def test_email_settings(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
settings = await _get_or_create_settings(db)
|
settings = await _get_or_create_settings(db)
|
||||||
if not settings.smtp_host:
|
if not settings.smtp_host:
|
||||||
raise HTTPException(status_code=400, detail="SMTP host is not configured")
|
raise HTTPException(status_code=400, detail=api_error("smtp_host_missing", "SMTP host is not configured"))
|
||||||
if not settings.from_email:
|
if not settings.from_email:
|
||||||
raise HTTPException(status_code=400, detail="From email is not configured")
|
raise HTTPException(status_code=400, detail=api_error("smtp_from_email_missing", "From email is not configured"))
|
||||||
|
|
||||||
password = decrypt_secret(settings.encrypted_smtp_password) if settings.encrypted_smtp_password else None
|
password = decrypt_secret(settings.encrypted_smtp_password) if settings.encrypted_smtp_password else None
|
||||||
message = EmailMessage()
|
message = EmailMessage()
|
||||||
@@ -126,7 +127,10 @@ async def test_email_settings(
|
|||||||
smtp.login(settings.smtp_username, password or "")
|
smtp.login(settings.smtp_username, password or "")
|
||||||
smtp.send_message(message)
|
smtp.send_message(message)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise HTTPException(status_code=400, detail=f"SMTP test failed: {exc}")
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=api_error("smtp_test_failed", "SMTP test failed", {"error": str(exc)}),
|
||||||
|
) from exc
|
||||||
|
|
||||||
await write_audit_log(db, "admin.email_settings.test", admin.id, {"recipient": str(payload.recipient)})
|
await write_audit_log(db, "admin.email_settings.test", admin.id, {"recipient": str(payload.recipient)})
|
||||||
return {"status": "sent", "recipient": str(payload.recipient)}
|
return {"status": "sent", "recipient": str(payload.recipient)}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
from app.core.deps import require_roles
|
from app.core.deps import require_roles
|
||||||
|
from app.core.errors import api_error
|
||||||
from app.core.security import hash_password
|
from app.core.security import hash_password
|
||||||
from app.models.models import User
|
from app.models.models import User
|
||||||
from app.schemas.user import UserCreate, UserOut, UserUpdate
|
from app.schemas.user import UserCreate, UserOut, UserUpdate
|
||||||
@@ -22,7 +23,7 @@ async def list_users(admin: User = Depends(require_roles("admin")), db: AsyncSes
|
|||||||
async def create_user(payload: UserCreate, admin: User = Depends(require_roles("admin")), db: AsyncSession = Depends(get_db)) -> UserOut:
|
async def create_user(payload: UserCreate, admin: User = Depends(require_roles("admin")), db: AsyncSession = Depends(get_db)) -> UserOut:
|
||||||
exists = await db.scalar(select(User).where(User.email == payload.email))
|
exists = await db.scalar(select(User).where(User.email == payload.email))
|
||||||
if exists:
|
if exists:
|
||||||
raise HTTPException(status_code=409, detail="Email already exists")
|
raise HTTPException(status_code=409, detail=api_error("email_exists", "Email already exists"))
|
||||||
user = User(
|
user = User(
|
||||||
email=payload.email,
|
email=payload.email,
|
||||||
first_name=payload.first_name,
|
first_name=payload.first_name,
|
||||||
@@ -46,13 +47,13 @@ async def update_user(
|
|||||||
) -> UserOut:
|
) -> UserOut:
|
||||||
user = await db.scalar(select(User).where(User.id == user_id))
|
user = await db.scalar(select(User).where(User.id == user_id))
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail=api_error("user_not_found", "User not found"))
|
||||||
update_data = payload.model_dump(exclude_unset=True)
|
update_data = payload.model_dump(exclude_unset=True)
|
||||||
next_email = update_data.get("email")
|
next_email = update_data.get("email")
|
||||||
if next_email and next_email != user.email:
|
if next_email and next_email != user.email:
|
||||||
existing = await db.scalar(select(User).where(User.email == next_email))
|
existing = await db.scalar(select(User).where(User.email == next_email))
|
||||||
if existing and existing.id != user.id:
|
if existing and existing.id != user.id:
|
||||||
raise HTTPException(status_code=409, detail="Email already exists")
|
raise HTTPException(status_code=409, detail=api_error("email_exists", "Email already exists"))
|
||||||
if "password" in update_data:
|
if "password" in update_data:
|
||||||
raw_password = update_data.pop("password")
|
raw_password = update_data.pop("password")
|
||||||
if raw_password:
|
if raw_password:
|
||||||
@@ -68,10 +69,10 @@ async def update_user(
|
|||||||
@router.delete("/{user_id}")
|
@router.delete("/{user_id}")
|
||||||
async def delete_user(user_id: int, admin: User = Depends(require_roles("admin")), db: AsyncSession = Depends(get_db)) -> dict:
|
async def delete_user(user_id: int, admin: User = Depends(require_roles("admin")), db: AsyncSession = Depends(get_db)) -> dict:
|
||||||
if user_id == admin.id:
|
if user_id == admin.id:
|
||||||
raise HTTPException(status_code=400, detail="Cannot delete yourself")
|
raise HTTPException(status_code=400, detail=api_error("cannot_delete_self", "Cannot delete yourself"))
|
||||||
user = await db.scalar(select(User).where(User.id == user_id))
|
user = await db.scalar(select(User).where(User.id == user_id))
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail=api_error("user_not_found", "User not found"))
|
||||||
await db.delete(user)
|
await db.delete(user)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await write_audit_log(db, "admin.user.delete", admin.id, {"deleted_user_id": user_id})
|
await write_audit_log(db, "admin.user.delete", admin.id, {"deleted_user_id": user_id})
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
from app.core.deps import get_current_user, require_roles
|
from app.core.deps import get_current_user, require_roles
|
||||||
|
from app.core.errors import api_error
|
||||||
from app.models.models import AlertDefinition, Target, User
|
from app.models.models import AlertDefinition, Target, User
|
||||||
from app.schemas.alert import (
|
from app.schemas.alert import (
|
||||||
AlertDefinitionCreate,
|
AlertDefinitionCreate,
|
||||||
@@ -33,7 +34,7 @@ async def _validate_target_exists(db: AsyncSession, target_id: int | None) -> No
|
|||||||
return
|
return
|
||||||
target_exists = await db.scalar(select(Target.id).where(Target.id == target_id))
|
target_exists = await db.scalar(select(Target.id).where(Target.id == target_id))
|
||||||
if target_exists is None:
|
if target_exists is None:
|
||||||
raise HTTPException(status_code=404, detail="Target not found")
|
raise HTTPException(status_code=404, detail=api_error("target_not_found", "Target not found"))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/status", response_model=AlertStatusResponse)
|
@router.get("/status", response_model=AlertStatusResponse)
|
||||||
@@ -101,7 +102,7 @@ async def update_alert_definition(
|
|||||||
) -> AlertDefinitionOut:
|
) -> AlertDefinitionOut:
|
||||||
definition = await db.scalar(select(AlertDefinition).where(AlertDefinition.id == definition_id))
|
definition = await db.scalar(select(AlertDefinition).where(AlertDefinition.id == definition_id))
|
||||||
if definition is None:
|
if definition is None:
|
||||||
raise HTTPException(status_code=404, detail="Alert definition not found")
|
raise HTTPException(status_code=404, detail=api_error("alert_definition_not_found", "Alert definition not found"))
|
||||||
|
|
||||||
updates = payload.model_dump(exclude_unset=True)
|
updates = payload.model_dump(exclude_unset=True)
|
||||||
if "target_id" in updates:
|
if "target_id" in updates:
|
||||||
@@ -131,7 +132,7 @@ async def delete_alert_definition(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
definition = await db.scalar(select(AlertDefinition).where(AlertDefinition.id == definition_id))
|
definition = await db.scalar(select(AlertDefinition).where(AlertDefinition.id == definition_id))
|
||||||
if definition is None:
|
if definition is None:
|
||||||
raise HTTPException(status_code=404, detail="Alert definition not found")
|
raise HTTPException(status_code=404, detail=api_error("alert_definition_not_found", "Alert definition not found"))
|
||||||
await db.delete(definition)
|
await db.delete(definition)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
invalidate_alert_cache()
|
invalidate_alert_cache()
|
||||||
@@ -148,7 +149,7 @@ async def test_alert_definition(
|
|||||||
_ = user
|
_ = user
|
||||||
target = await db.scalar(select(Target).where(Target.id == payload.target_id))
|
target = await db.scalar(select(Target).where(Target.id == payload.target_id))
|
||||||
if target is None:
|
if target is None:
|
||||||
raise HTTPException(status_code=404, detail="Target not found")
|
raise HTTPException(status_code=404, detail=api_error("target_not_found", "Target not found"))
|
||||||
try:
|
try:
|
||||||
value = await run_scalar_sql_for_target(target, payload.sql_text)
|
value = await run_scalar_sql_for_target(target, payload.sql_text)
|
||||||
return AlertDefinitionTestResponse(ok=True, value=value)
|
return AlertDefinitionTestResponse(ok=True, value=value)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
from app.core.deps import get_current_user
|
from app.core.deps import get_current_user
|
||||||
|
from app.core.errors import api_error
|
||||||
from app.core.security import create_access_token, create_refresh_token, verify_password
|
from app.core.security import create_access_token, create_refresh_token, verify_password
|
||||||
from app.models.models import User
|
from app.models.models import User
|
||||||
from app.schemas.auth import LoginRequest, RefreshRequest, TokenResponse
|
from app.schemas.auth import LoginRequest, RefreshRequest, TokenResponse
|
||||||
@@ -19,7 +20,10 @@ settings = get_settings()
|
|||||||
async def login(payload: LoginRequest, db: AsyncSession = Depends(get_db)) -> TokenResponse:
|
async def login(payload: LoginRequest, db: AsyncSession = Depends(get_db)) -> TokenResponse:
|
||||||
user = await db.scalar(select(User).where(User.email == payload.email))
|
user = await db.scalar(select(User).where(User.email == payload.email))
|
||||||
if not user or not verify_password(payload.password, user.password_hash):
|
if not user or not verify_password(payload.password, user.password_hash):
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail=api_error("invalid_credentials", "Invalid credentials"),
|
||||||
|
)
|
||||||
|
|
||||||
await write_audit_log(db, action="auth.login", user_id=user.id, payload={"email": user.email})
|
await write_audit_log(db, action="auth.login", user_id=user.id, payload={"email": user.email})
|
||||||
return TokenResponse(access_token=create_access_token(str(user.id)), refresh_token=create_refresh_token(str(user.id)))
|
return TokenResponse(access_token=create_access_token(str(user.id)), refresh_token=create_refresh_token(str(user.id)))
|
||||||
@@ -30,14 +34,23 @@ async def refresh(payload: RefreshRequest, db: AsyncSession = Depends(get_db)) -
|
|||||||
try:
|
try:
|
||||||
token_payload = jwt.decode(payload.refresh_token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm])
|
token_payload = jwt.decode(payload.refresh_token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm])
|
||||||
except jwt.InvalidTokenError as exc:
|
except jwt.InvalidTokenError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token") from exc
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail=api_error("invalid_refresh_token", "Invalid refresh token"),
|
||||||
|
) from exc
|
||||||
|
|
||||||
if token_payload.get("type") != "refresh":
|
if token_payload.get("type") != "refresh":
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token type")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail=api_error("invalid_refresh_token_type", "Invalid refresh token type"),
|
||||||
|
)
|
||||||
user_id = token_payload.get("sub")
|
user_id = token_payload.get("sub")
|
||||||
user = await db.scalar(select(User).where(User.id == int(user_id)))
|
user = await db.scalar(select(User).where(User.id == int(user_id)))
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail=api_error("user_not_found", "User not found"),
|
||||||
|
)
|
||||||
|
|
||||||
await write_audit_log(db, action="auth.refresh", user_id=user.id, payload={})
|
await write_audit_log(db, action="auth.refresh", user_id=user.id, payload={})
|
||||||
return TokenResponse(access_token=create_access_token(str(user.id)), refresh_token=create_refresh_token(str(user.id)))
|
return TokenResponse(access_token=create_access_token(str(user.id)), refresh_token=create_refresh_token(str(user.id)))
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
from app.core.deps import get_current_user
|
from app.core.deps import get_current_user
|
||||||
|
from app.core.errors import api_error
|
||||||
from app.core.security import hash_password, verify_password
|
from app.core.security import hash_password, verify_password
|
||||||
from app.models.models import User
|
from app.models.models import User
|
||||||
from app.schemas.user import UserOut, UserPasswordChange
|
from app.schemas.user import UserOut, UserPasswordChange
|
||||||
@@ -22,10 +23,16 @@ async def change_password(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
if not verify_password(payload.current_password, user.password_hash):
|
if not verify_password(payload.current_password, user.password_hash):
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Current password is incorrect")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=api_error("invalid_current_password", "Current password is incorrect"),
|
||||||
|
)
|
||||||
|
|
||||||
if verify_password(payload.new_password, user.password_hash):
|
if verify_password(payload.new_password, user.password_hash):
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="New password must be different")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=api_error("password_reuse_not_allowed", "New password must be different"),
|
||||||
|
)
|
||||||
|
|
||||||
user.password_hash = hash_password(payload.new_password)
|
user.password_hash = hash_password(payload.new_password)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
from app.core.deps import get_current_user, require_roles
|
from app.core.deps import get_current_user, require_roles
|
||||||
|
from app.core.errors import api_error
|
||||||
from app.models.models import Metric, QueryStat, Target, TargetOwner, User
|
from app.models.models import Metric, QueryStat, Target, TargetOwner, User
|
||||||
from app.schemas.metric import MetricOut, QueryStatOut
|
from app.schemas.metric import MetricOut, QueryStatOut
|
||||||
from app.schemas.overview import DatabaseOverviewOut
|
from app.schemas.overview import DatabaseOverviewOut
|
||||||
@@ -85,7 +86,10 @@ async def _discover_databases(payload: TargetCreate) -> list[str]:
|
|||||||
)
|
)
|
||||||
return [row["datname"] for row in rows if row["datname"]]
|
return [row["datname"] for row in rows if row["datname"]]
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise HTTPException(status_code=400, detail=f"Database discovery failed: {exc}")
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=api_error("database_discovery_failed", "Database discovery failed", {"error": str(exc)}),
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
if conn:
|
if conn:
|
||||||
await conn.close()
|
await conn.close()
|
||||||
@@ -131,7 +135,10 @@ async def test_target_connection(
|
|||||||
version = await conn.fetchval("SHOW server_version")
|
version = await conn.fetchval("SHOW server_version")
|
||||||
return {"ok": True, "message": "Connection successful", "server_version": version}
|
return {"ok": True, "message": "Connection successful", "server_version": version}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise HTTPException(status_code=400, detail=f"Connection failed: {exc}")
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=api_error("connection_test_failed", "Connection test failed", {"error": str(exc)}),
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
if conn:
|
if conn:
|
||||||
await conn.close()
|
await conn.close()
|
||||||
@@ -147,7 +154,10 @@ async def create_target(
|
|||||||
if owner_ids:
|
if owner_ids:
|
||||||
owners_exist = (await db.scalars(select(User.id).where(User.id.in_(owner_ids)))).all()
|
owners_exist = (await db.scalars(select(User.id).where(User.id.in_(owner_ids)))).all()
|
||||||
if len(set(owners_exist)) != len(owner_ids):
|
if len(set(owners_exist)) != len(owner_ids):
|
||||||
raise HTTPException(status_code=400, detail="One or more owner users were not found")
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=api_error("owner_users_not_found", "One or more owner users were not found"),
|
||||||
|
)
|
||||||
|
|
||||||
encrypted_password = encrypt_secret(payload.password)
|
encrypted_password = encrypt_secret(payload.password)
|
||||||
created_targets: list[Target] = []
|
created_targets: list[Target] = []
|
||||||
@@ -155,7 +165,10 @@ async def create_target(
|
|||||||
if payload.discover_all_databases:
|
if payload.discover_all_databases:
|
||||||
databases = await _discover_databases(payload)
|
databases = await _discover_databases(payload)
|
||||||
if not databases:
|
if not databases:
|
||||||
raise HTTPException(status_code=400, detail="No databases discovered on target")
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=api_error("no_databases_discovered", "No databases discovered on target"),
|
||||||
|
)
|
||||||
group_id = str(uuid4())
|
group_id = str(uuid4())
|
||||||
base_tags = payload.tags or {}
|
base_tags = payload.tags or {}
|
||||||
for dbname in databases:
|
for dbname in databases:
|
||||||
@@ -194,7 +207,10 @@ async def create_target(
|
|||||||
await _set_target_owners(db, target.id, owner_ids, user.id)
|
await _set_target_owners(db, target.id, owner_ids, user.id)
|
||||||
|
|
||||||
if not created_targets:
|
if not created_targets:
|
||||||
raise HTTPException(status_code=400, detail="All discovered databases already exist as targets")
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=api_error("all_discovered_databases_exist", "All discovered databases already exist as targets"),
|
||||||
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
for item in created_targets:
|
for item in created_targets:
|
||||||
await db.refresh(item)
|
await db.refresh(item)
|
||||||
@@ -247,7 +263,7 @@ async def get_target(target_id: int, user: User = Depends(get_current_user), db:
|
|||||||
_ = user
|
_ = user
|
||||||
target = await db.scalar(select(Target).where(Target.id == target_id))
|
target = await db.scalar(select(Target).where(Target.id == target_id))
|
||||||
if not target:
|
if not target:
|
||||||
raise HTTPException(status_code=404, detail="Target not found")
|
raise HTTPException(status_code=404, detail=api_error("target_not_found", "Target not found"))
|
||||||
owner_map = await _owners_by_target_ids(db, [target.id])
|
owner_map = await _owners_by_target_ids(db, [target.id])
|
||||||
return _target_out_with_owners(target, owner_map.get(target.id, []))
|
return _target_out_with_owners(target, owner_map.get(target.id, []))
|
||||||
|
|
||||||
@@ -261,7 +277,7 @@ async def update_target(
|
|||||||
) -> TargetOut:
|
) -> TargetOut:
|
||||||
target = await db.scalar(select(Target).where(Target.id == target_id))
|
target = await db.scalar(select(Target).where(Target.id == target_id))
|
||||||
if not target:
|
if not target:
|
||||||
raise HTTPException(status_code=404, detail="Target not found")
|
raise HTTPException(status_code=404, detail=api_error("target_not_found", "Target not found"))
|
||||||
|
|
||||||
updates = payload.model_dump(exclude_unset=True)
|
updates = payload.model_dump(exclude_unset=True)
|
||||||
owner_user_ids = updates.pop("owner_user_ids", None)
|
owner_user_ids = updates.pop("owner_user_ids", None)
|
||||||
@@ -273,7 +289,10 @@ async def update_target(
|
|||||||
if owner_user_ids is not None:
|
if owner_user_ids is not None:
|
||||||
owners_exist = (await db.scalars(select(User.id).where(User.id.in_(owner_user_ids)))).all()
|
owners_exist = (await db.scalars(select(User.id).where(User.id.in_(owner_user_ids)))).all()
|
||||||
if len(set(owners_exist)) != len(set(owner_user_ids)):
|
if len(set(owners_exist)) != len(set(owner_user_ids)):
|
||||||
raise HTTPException(status_code=400, detail="One or more owner users were not found")
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=api_error("owner_users_not_found", "One or more owner users were not found"),
|
||||||
|
)
|
||||||
await _set_target_owners(db, target.id, owner_user_ids, user.id)
|
await _set_target_owners(db, target.id, owner_user_ids, user.id)
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
@@ -292,12 +311,15 @@ async def set_target_owners(
|
|||||||
) -> list[TargetOwnerOut]:
|
) -> list[TargetOwnerOut]:
|
||||||
target = await db.scalar(select(Target).where(Target.id == target_id))
|
target = await db.scalar(select(Target).where(Target.id == target_id))
|
||||||
if not target:
|
if not target:
|
||||||
raise HTTPException(status_code=404, detail="Target not found")
|
raise HTTPException(status_code=404, detail=api_error("target_not_found", "Target not found"))
|
||||||
owner_user_ids = sorted(set(payload.user_ids))
|
owner_user_ids = sorted(set(payload.user_ids))
|
||||||
if owner_user_ids:
|
if owner_user_ids:
|
||||||
owners_exist = (await db.scalars(select(User.id).where(User.id.in_(owner_user_ids)))).all()
|
owners_exist = (await db.scalars(select(User.id).where(User.id.in_(owner_user_ids)))).all()
|
||||||
if len(set(owners_exist)) != len(set(owner_user_ids)):
|
if len(set(owners_exist)) != len(set(owner_user_ids)):
|
||||||
raise HTTPException(status_code=400, detail="One or more owner users were not found")
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=api_error("owner_users_not_found", "One or more owner users were not found"),
|
||||||
|
)
|
||||||
await _set_target_owners(db, target_id, owner_user_ids, user.id)
|
await _set_target_owners(db, target_id, owner_user_ids, user.id)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await write_audit_log(db, "target.owners.update", user.id, {"target_id": target_id, "owner_user_ids": owner_user_ids})
|
await write_audit_log(db, "target.owners.update", user.id, {"target_id": target_id, "owner_user_ids": owner_user_ids})
|
||||||
@@ -321,7 +343,7 @@ async def get_target_owners(
|
|||||||
_ = user
|
_ = user
|
||||||
target = await db.scalar(select(Target).where(Target.id == target_id))
|
target = await db.scalar(select(Target).where(Target.id == target_id))
|
||||||
if not target:
|
if not target:
|
||||||
raise HTTPException(status_code=404, detail="Target not found")
|
raise HTTPException(status_code=404, detail=api_error("target_not_found", "Target not found"))
|
||||||
rows = (
|
rows = (
|
||||||
await db.execute(
|
await db.execute(
|
||||||
select(User.id, User.email, User.role)
|
select(User.id, User.email, User.role)
|
||||||
@@ -341,7 +363,7 @@ async def delete_target(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
target = await db.scalar(select(Target).where(Target.id == target_id))
|
target = await db.scalar(select(Target).where(Target.id == target_id))
|
||||||
if not target:
|
if not target:
|
||||||
raise HTTPException(status_code=404, detail="Target not found")
|
raise HTTPException(status_code=404, detail=api_error("target_not_found", "Target not found"))
|
||||||
await db.delete(target)
|
await db.delete(target)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await write_audit_log(db, "target.delete", user.id, {"target_id": target_id})
|
await write_audit_log(db, "target.delete", user.id, {"target_id": target_id})
|
||||||
@@ -369,7 +391,22 @@ async def get_metrics(
|
|||||||
|
|
||||||
|
|
||||||
async def _live_conn(target: Target) -> asyncpg.Connection:
|
async def _live_conn(target: Target) -> asyncpg.Connection:
|
||||||
|
try:
|
||||||
return await asyncpg.connect(dsn=build_target_dsn(target))
|
return await asyncpg.connect(dsn=build_target_dsn(target))
|
||||||
|
except (OSError, asyncpg.PostgresError) as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail=api_error(
|
||||||
|
"target_unreachable",
|
||||||
|
"Target database is not reachable",
|
||||||
|
{
|
||||||
|
"target_id": target.id,
|
||||||
|
"host": target.host,
|
||||||
|
"port": target.port,
|
||||||
|
"error": str(exc),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{target_id}/locks")
|
@router.get("/{target_id}/locks")
|
||||||
@@ -377,7 +414,7 @@ async def get_locks(target_id: int, user: User = Depends(get_current_user), db:
|
|||||||
_ = user
|
_ = user
|
||||||
target = await db.scalar(select(Target).where(Target.id == target_id))
|
target = await db.scalar(select(Target).where(Target.id == target_id))
|
||||||
if not target:
|
if not target:
|
||||||
raise HTTPException(status_code=404, detail="Target not found")
|
raise HTTPException(status_code=404, detail=api_error("target_not_found", "Target not found"))
|
||||||
conn = await _live_conn(target)
|
conn = await _live_conn(target)
|
||||||
try:
|
try:
|
||||||
rows = await conn.fetch(
|
rows = await conn.fetch(
|
||||||
@@ -398,7 +435,7 @@ async def get_activity(target_id: int, user: User = Depends(get_current_user), d
|
|||||||
_ = user
|
_ = user
|
||||||
target = await db.scalar(select(Target).where(Target.id == target_id))
|
target = await db.scalar(select(Target).where(Target.id == target_id))
|
||||||
if not target:
|
if not target:
|
||||||
raise HTTPException(status_code=404, detail="Target not found")
|
raise HTTPException(status_code=404, detail=api_error("target_not_found", "Target not found"))
|
||||||
conn = await _live_conn(target)
|
conn = await _live_conn(target)
|
||||||
try:
|
try:
|
||||||
rows = await conn.fetch(
|
rows = await conn.fetch(
|
||||||
@@ -420,7 +457,7 @@ async def get_top_queries(target_id: int, user: User = Depends(get_current_user)
|
|||||||
_ = user
|
_ = user
|
||||||
target = await db.scalar(select(Target).where(Target.id == target_id))
|
target = await db.scalar(select(Target).where(Target.id == target_id))
|
||||||
if not target:
|
if not target:
|
||||||
raise HTTPException(status_code=404, detail="Target not found")
|
raise HTTPException(status_code=404, detail=api_error("target_not_found", "Target not found"))
|
||||||
if not target.use_pg_stat_statements:
|
if not target.use_pg_stat_statements:
|
||||||
return []
|
return []
|
||||||
rows = (
|
rows = (
|
||||||
@@ -450,5 +487,20 @@ async def get_overview(target_id: int, user: User = Depends(get_current_user), d
|
|||||||
_ = user
|
_ = user
|
||||||
target = await db.scalar(select(Target).where(Target.id == target_id))
|
target = await db.scalar(select(Target).where(Target.id == target_id))
|
||||||
if not target:
|
if not target:
|
||||||
raise HTTPException(status_code=404, detail="Target not found")
|
raise HTTPException(status_code=404, detail=api_error("target_not_found", "Target not found"))
|
||||||
|
try:
|
||||||
return await get_target_overview(target)
|
return await get_target_overview(target)
|
||||||
|
except (OSError, asyncpg.PostgresError) as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail=api_error(
|
||||||
|
"target_unreachable",
|
||||||
|
"Target database is not reachable",
|
||||||
|
{
|
||||||
|
"target_id": target.id,
|
||||||
|
"host": target.host,
|
||||||
|
"port": target.port,
|
||||||
|
"error": str(exc),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
) from exc
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from functools import lru_cache
|
|||||||
from pydantic import field_validator
|
from pydantic import field_validator
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
NEXAPG_VERSION = "0.1.8"
|
NEXAPG_VERSION = "0.2.0"
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
|
from app.core.errors import api_error
|
||||||
from app.models.models import User
|
from app.models.models import User
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
@@ -16,27 +17,42 @@ async def get_current_user(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> User:
|
) -> User:
|
||||||
if not credentials:
|
if not credentials:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing token")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail=api_error("missing_token", "Missing token"),
|
||||||
|
)
|
||||||
token = credentials.credentials
|
token = credentials.credentials
|
||||||
try:
|
try:
|
||||||
payload = jwt.decode(token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm])
|
payload = jwt.decode(token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm])
|
||||||
except jwt.InvalidTokenError as exc:
|
except jwt.InvalidTokenError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") from exc
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail=api_error("invalid_token", "Invalid token"),
|
||||||
|
) from exc
|
||||||
|
|
||||||
if payload.get("type") != "access":
|
if payload.get("type") != "access":
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token type")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail=api_error("invalid_token_type", "Invalid token type"),
|
||||||
|
)
|
||||||
|
|
||||||
user_id = payload.get("sub")
|
user_id = payload.get("sub")
|
||||||
user = await db.scalar(select(User).where(User.id == int(user_id)))
|
user = await db.scalar(select(User).where(User.id == int(user_id)))
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail=api_error("user_not_found", "User not found"),
|
||||||
|
)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
def require_roles(*roles: str):
|
def require_roles(*roles: str):
|
||||||
async def role_dependency(user: User = Depends(get_current_user)) -> User:
|
async def role_dependency(user: User = Depends(get_current_user)) -> User:
|
||||||
if user.role not in roles:
|
if user.role not in roles:
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=api_error("forbidden", "Forbidden"),
|
||||||
|
)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
return role_dependency
|
return role_dependency
|
||||||
|
|||||||
@@ -12,6 +12,14 @@ def error_payload(code: str, message: str, details: Any, request_id: str) -> dic
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def api_error(code: str, message: str, details: Any = None) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"code": code,
|
||||||
|
"message": message,
|
||||||
|
"details": details,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def http_status_to_code(status_code: int) -> str:
|
def http_status_to_code(status_code: int) -> str:
|
||||||
mapping = {
|
mapping = {
|
||||||
400: "bad_request",
|
400: "bad_request",
|
||||||
@@ -28,4 +36,3 @@ def http_status_to_code(status_code: int) -> str:
|
|||||||
504: "gateway_timeout",
|
504: "gateway_timeout",
|
||||||
}
|
}
|
||||||
return mapping.get(status_code, f"http_{status_code}")
|
return mapping.get(status_code, f"http_{status_code}")
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from sqlalchemy import desc, func, select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
|
from app.core.errors import api_error
|
||||||
from app.models.models import AlertDefinition, Metric, QueryStat, Target
|
from app.models.models import AlertDefinition, Metric, QueryStat, Target
|
||||||
from app.schemas.alert import AlertStatusItem, AlertStatusResponse
|
from app.schemas.alert import AlertStatusItem, AlertStatusResponse
|
||||||
from app.services.collector import build_target_dsn
|
from app.services.collector import build_target_dsn
|
||||||
@@ -144,25 +145,40 @@ def get_standard_alert_reference() -> list[dict[str, str]]:
|
|||||||
|
|
||||||
def validate_alert_thresholds(comparison: str, warning_threshold: float | None, alert_threshold: float) -> None:
|
def validate_alert_thresholds(comparison: str, warning_threshold: float | None, alert_threshold: float) -> None:
|
||||||
if comparison not in _ALLOWED_COMPARISONS:
|
if comparison not in _ALLOWED_COMPARISONS:
|
||||||
raise HTTPException(status_code=400, detail=f"Invalid comparison. Use one of {sorted(_ALLOWED_COMPARISONS)}")
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=api_error(
|
||||||
|
"invalid_comparison",
|
||||||
|
f"Invalid comparison. Use one of {sorted(_ALLOWED_COMPARISONS)}",
|
||||||
|
),
|
||||||
|
)
|
||||||
if warning_threshold is None:
|
if warning_threshold is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
if comparison in {"gte", "gt"} and warning_threshold > alert_threshold:
|
if comparison in {"gte", "gt"} and warning_threshold > alert_threshold:
|
||||||
raise HTTPException(status_code=400, detail="For gte/gt, warning_threshold must be <= alert_threshold")
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=api_error("invalid_thresholds", "For gte/gt, warning_threshold must be <= alert_threshold"),
|
||||||
|
)
|
||||||
if comparison in {"lte", "lt"} and warning_threshold < alert_threshold:
|
if comparison in {"lte", "lt"} and warning_threshold < alert_threshold:
|
||||||
raise HTTPException(status_code=400, detail="For lte/lt, warning_threshold must be >= alert_threshold")
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=api_error("invalid_thresholds", "For lte/lt, warning_threshold must be >= alert_threshold"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def validate_alert_sql(sql_text: str) -> str:
|
def validate_alert_sql(sql_text: str) -> str:
|
||||||
sql = sql_text.strip().rstrip(";")
|
sql = sql_text.strip().rstrip(";")
|
||||||
lowered = sql.lower().strip()
|
lowered = sql.lower().strip()
|
||||||
if not lowered.startswith("select"):
|
if not lowered.startswith("select"):
|
||||||
raise HTTPException(status_code=400, detail="Alert SQL must start with SELECT")
|
raise HTTPException(status_code=400, detail=api_error("invalid_alert_sql", "Alert SQL must start with SELECT"))
|
||||||
if _FORBIDDEN_SQL_WORDS.search(lowered):
|
if _FORBIDDEN_SQL_WORDS.search(lowered):
|
||||||
raise HTTPException(status_code=400, detail="Only read-only SELECT statements are allowed")
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=api_error("invalid_alert_sql", "Only read-only SELECT statements are allowed"),
|
||||||
|
)
|
||||||
if ";" in sql:
|
if ";" in sql:
|
||||||
raise HTTPException(status_code=400, detail="Only a single SQL statement is allowed")
|
raise HTTPException(status_code=400, detail=api_error("invalid_alert_sql", "Only a single SQL statement is allowed"))
|
||||||
return sql
|
return sql
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -195,6 +195,7 @@ async def collect_once() -> None:
|
|||||||
except (OSError, SQLAlchemyError, asyncpg.PostgresError, Exception) as exc:
|
except (OSError, SQLAlchemyError, asyncpg.PostgresError, Exception) as exc:
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
current_error = str(exc)
|
current_error = str(exc)
|
||||||
|
error_class = exc.__class__.__name__
|
||||||
state = _failure_state.get(target.id)
|
state = _failure_state.get(target.id)
|
||||||
if state is None:
|
if state is None:
|
||||||
_failure_state[target.id] = {
|
_failure_state[target.id] = {
|
||||||
@@ -202,7 +203,13 @@ async def collect_once() -> None:
|
|||||||
"last_log_at": now,
|
"last_log_at": now,
|
||||||
"error": current_error,
|
"error": current_error,
|
||||||
}
|
}
|
||||||
logger.exception("collector_error target=%s err=%s", target.id, exc)
|
logger.warning(
|
||||||
|
"collector_target_unreachable target=%s error_class=%s err=%s consecutive_failures=%s",
|
||||||
|
target.id,
|
||||||
|
error_class,
|
||||||
|
current_error,
|
||||||
|
1,
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
count = int(state.get("count", 0)) + 1
|
count = int(state.get("count", 0)) + 1
|
||||||
@@ -220,9 +227,10 @@ async def collect_once() -> None:
|
|||||||
if should_log:
|
if should_log:
|
||||||
state["last_log_at"] = now
|
state["last_log_at"] = now
|
||||||
state["error"] = current_error
|
state["error"] = current_error
|
||||||
logger.error(
|
logger.warning(
|
||||||
"collector_error_throttled target=%s err=%s consecutive_failures=%s",
|
"collector_target_unreachable target=%s error_class=%s err=%s consecutive_failures=%s",
|
||||||
target.id,
|
target.id,
|
||||||
|
error_class,
|
||||||
current_error,
|
current_error,
|
||||||
count,
|
count,
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user