Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/app/api/routes/collections.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from fastapi import Path as FastPath

from app.api.deps import SessionDep, AuthContextDep
from app.crud.collection.collection import CollectionNameConflictError
from app.api.permissions import Permission, require_permission
from app.core.telemetry import log_context
from app.core.rate_monitor import monitor_rate
Expand All @@ -14,7 +15,6 @@
CollectionJobCrud,
DocumentCollectionCrud,
)
from app.crud.collection.collection import CollectionNameConflictError
from app.core.cloud import get_cloud_storage
from app.models import (
CollectionJobStatus,
Expand Down
9 changes: 5 additions & 4 deletions backend/app/api/routes/stt_evaluations/evaluation.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ def start_stt_evaluation(
sample_count = (dataset.dataset_metadata or {}).get("sample_count", 0)

if sample_count == 0:
raise HTTPException(status_code=400, detail="Dataset has no samples")
raise HTTPException(status_code=422, detail="Dataset has no samples")

# Use language_id from the dataset
language_id = dataset.language_id
Expand Down Expand Up @@ -97,7 +97,8 @@ def start_stt_evaluation(
except Exception as e:
logger.error(
f"[start_stt_evaluation] Failed to queue batch submission | "
f"run_id: {run.id}, error: {str(e)}"
f"run_id: {run.id}, error: {str(e)}",
exc_info=True,
)
update_stt_run(
session=session,
Expand All @@ -106,8 +107,8 @@ def start_stt_evaluation(
error_message=f"Failed to queue batch submission: {str(e)}",
)
raise HTTPException(
status_code=500,
detail=f"Failed to queue batch submission: {e}",
status_code=503,
detail="Could not queue the batch submission. Retry shortly.",
)

return APIResponse.success_response(
Expand Down
9 changes: 5 additions & 4 deletions backend/app/api/routes/tts_evaluations/evaluation.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ def start_tts_evaluation(
sample_count = (dataset.dataset_metadata or {}).get("sample_count", 0)

if sample_count == 0:
raise HTTPException(status_code=400, detail="Dataset has no samples")
raise HTTPException(status_code=422, detail="Dataset has no samples")

language_id = dataset.language_id

Expand Down Expand Up @@ -101,7 +101,8 @@ def start_tts_evaluation(
except Exception as e:
logger.error(
f"[start_tts_evaluation] Failed to queue batch submission | "
f"run_id: {run.id}, error: {str(e)}"
f"run_id: {run.id}, error: {str(e)}",
exc_info=True,
)
update_tts_run(
session=session,
Expand All @@ -110,8 +111,8 @@ def start_tts_evaluation(
error_message=f"Failed to queue batch submission: {str(e)}",
)
raise HTTPException(
status_code=500,
detail=f"Failed to queue batch submission: {e}",
status_code=503,
detail="Could not queue the batch submission. Retry shortly.",
)

return APIResponse.success_response(
Expand Down
31 changes: 28 additions & 3 deletions backend/app/core/exception_handlers.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import logging
import re
from collections import defaultdict

import sentry_sdk
from asgi_correlation_id import correlation_id
from fastapi import FastAPI, Request, HTTPException
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
Expand All @@ -11,6 +14,10 @@

from app.utils import APIResponse

logger = logging.getLogger(__name__)

GENERIC_ERROR_DETAIL = "An unexpected error occurred."

_BRANCH_PATTERN = re.compile(r"^[A-Z]|[\[\]()]")


Expand Down Expand Up @@ -102,16 +109,34 @@ async def http_exception_handler(
detail = exc.detail
if isinstance(detail, list):
detail = _sanitize_validation_errors(detail)

# Tag at the boundary: the status is only known here, and call-site logs
# fire before it. 5xx is a server fault (error event); 4xx is the caller's.
if sentry_sdk.get_client().is_active():
sentry_sdk.set_tag("http.status_code", str(exc.status_code))
sentry_sdk.set_tag(
"error.class", "server" if exc.status_code >= 500 else "client"
)
if exc.status_code >= 500:
logger.error(
f"[http_exception] {request.method} {request.url.path} "
f"-> {exc.status_code}",
exc_info=exc,
)
return JSONResponse(
status_code=exc.status_code,
content=APIResponse.failure_response(detail).model_dump(),
)

@app.exception_handler(Exception)
async def generic_error_handler(request: Request, exc: Exception) -> JSONResponse:
# Fixed detail: str(exc) here leaked provider bodies and DB text to callers.
logger.error(
f"[generic_error_handler] Unhandled exception | "
f"path: {request.url.path}, trace_id: {correlation_id.get() or 'N/A'}",
exc_info=True,
)
return JSONResponse(
status_code=HTTP_500_INTERNAL_SERVER_ERROR,
content=APIResponse.failure_response(
str(exc) or "An unexpected error occurred."
).model_dump(),
content=APIResponse.failure_response(GENERIC_ERROR_DETAIL).model_dump(),
)
17 changes: 13 additions & 4 deletions backend/app/core/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import base64
import json
import logging
from fastapi import HTTPException
import os
import secrets
from datetime import UTC, datetime, timedelta
Expand Down Expand Up @@ -212,8 +213,12 @@ def encrypt_credentials(credentials: dict[str, Any]) -> str:
return get_fernet().encrypt(credentials_str.encode()).decode()
except Exception as e:
# Log the real cause (may carry AWS ARNs); never surface it to callers.
logger.error(f"[encrypt_credentials] Encryption failed | error: {e}")
raise ValueError("Failed to encrypt credentials")
logger.error(
f"[encrypt_credentials] Encryption failed | error: {e}", exc_info=True
)
raise HTTPException(
status_code=502, detail="Failed to encrypt credentials. Retry shortly."
)


def decrypt_credentials(encrypted_credentials: str) -> dict[str, Any]:
Expand All @@ -232,8 +237,12 @@ def decrypt_credentials(encrypted_credentials: str) -> dict[str, Any]:
return json.loads(decrypted_str)
except Exception as e:
# Log the real cause (may carry AWS ARNs); never surface it to callers.
logger.error(f"[decrypt_credentials] Decryption failed | error: {e}")
raise ValueError("Failed to decrypt credentials")
logger.error(
f"[decrypt_credentials] Decryption failed | error: {e}", exc_info=True
)
raise HTTPException(
status_code=502, detail="Failed to decrypt credentials. Retry shortly."
)


class APIKeyManager:
Expand Down
2 changes: 1 addition & 1 deletion backend/app/crud/collection/collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ def read_one_if_delete(self, collection_id: UUID) -> Collection:
"[CollectionCrud.read_one_if_delete] Collection already deleted | "
f"{{'project_id': '{self.project_id}', 'collection_id': '{collection_id}'}}"
)
raise HTTPException(status_code=400, detail="Collection already deleted")
raise HTTPException(status_code=409, detail="Collection already deleted")

return collection

Expand Down
16 changes: 16 additions & 0 deletions backend/app/crud/config/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from uuid import UUID

from fastapi import HTTPException
from sqlalchemy.exc import IntegrityError
from sqlmodel import Session, and_, select

from app.core.util import now
Expand Down Expand Up @@ -67,6 +68,21 @@ def create_or_raise(

return config, version

except HTTPException:
self.session.rollback()
raise

except IntegrityError as e:
self.session.rollback()
logger.warning(
f"[ConfigCrud.create] Duplicate configuration name | "
f"{{'name': '{config_create.name}', 'project_id': {self.project_id}, 'error': '{str(e)}'}}",
)
raise HTTPException(
status_code=409,
detail=f"Config with name '{config_create.name}' already exists in this project.",
)

except Exception as e:
self.session.rollback()
logger.error(
Expand Down
22 changes: 19 additions & 3 deletions backend/app/crud/config/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from fastapi import HTTPException
from pydantic import ValidationError
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import defer
from sqlmodel import Session, and_, select

Expand Down Expand Up @@ -82,7 +83,7 @@ def create_or_raise(self, version_create: ConfigVersionUpdate) -> ConfigVersion:
f"{{'config_id': '{self.config_id}', 'error_count': {len(validation_errors)}, "
f"'fields': {['.'.join(str(part) for part in err['loc']) for err in validation_errors]}}}"
)
raise HTTPException(status_code=400, detail=validation_errors)
raise HTTPException(status_code=422, detail=validation_errors)

validate_blob_model_or_raise(self.session, validated_blob)

Expand All @@ -101,16 +102,31 @@ def create_or_raise(self, version_create: ConfigVersionUpdate) -> ConfigVersion:
self.session.refresh(version)

logger.info(
f"[ConfigVersionCrud.create_from_partial] Version created successfully | "
f"[ConfigVersionCrud.create_or_raise] Version created successfully | "
f"{{'config_id': '{self.config_id}', 'version_id': '{version.id}'}}"
)

return version

except HTTPException:
self.session.rollback()
raise

except IntegrityError as e:
self.session.rollback()
logger.warning(
f"[ConfigVersionCrud.create_or_raise] Version conflict | "
f"{{'config_id': '{self.config_id}', 'error': '{str(e)}'}}",
)
raise HTTPException(
status_code=409,
detail="A version with this number already exists for this config.",
)

except Exception as e:
self.session.rollback()
logger.error(
f"[ConfigVersionCrud.create_from_partial] Failed to create version | "
f"[ConfigVersionCrud.create_or_raise] Failed to create version | "
f"{{'config_id': '{self.config_id}', 'error': '{str(e)}'}}",
exc_info=True,
)
Expand Down
44 changes: 29 additions & 15 deletions backend/app/crud/credentials.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
from sqlalchemy.exc import IntegrityError
from sqlmodel import Session, select

from app.core.exception_handlers import HTTPException
from fastapi import HTTPException

from app.core.providers import validate_provider, validate_provider_credentials
from app.core.security import decrypt_credentials, encrypt_credentials
from app.core.util import now
Expand Down Expand Up @@ -54,21 +55,23 @@ def set_creds_for_org(
created_credentials.append(credential)
except IntegrityError as e:
session.rollback()
logger.error(
is_duplicate = (
"uq_credential_org_project_provider" in str(e)
or "unique constraint" in str(e).lower()
)
log = logger.warning if is_duplicate else logger.error
log(
f"[set_creds_for_org] Integrity error while adding credentials | organization_id {organization_id}, project_id {project_id}, provider {provider}: {str(e)}",
exc_info=True,
)
# Check if it's a duplicate constraint violation
if (
"uq_credential_org_project_provider" in str(e)
or "unique constraint" in str(e).lower()
):
if is_duplicate:
raise HTTPException(
status_code=400,
status_code=409,
detail=f"Credentials for provider '{provider}' already exist for this organization and project combination",
)
raise ValueError(
f"Error while adding credentials for provider {provider}: {str(e)}"
raise HTTPException(
status_code=500,
detail=f"Could not store credentials for provider '{provider}'. Contact Kaapi if it persists.",
)
logger.info(
f"[set_creds_for_org] Successfully created credentials | organization_id {organization_id}, project_id {project_id}"
Expand Down Expand Up @@ -187,7 +190,9 @@ def update_creds_for_org(
) -> list[Credential]:
"""Updates credentials for a specific provider of an organization."""
if not creds_in.provider or not creds_in.credential:
raise ValueError("Provider and credential must be provided")
raise HTTPException(
status_code=400, detail="Provider and credential must be provided"
)

# Auto-unwrap nested format: {"google": {"api_key": "..."}} -> {"api_key": "..."}
# so the same payload shape works for both create and update.
Expand Down Expand Up @@ -280,12 +285,11 @@ def remove_provider_credential(
rows_deleted = result.rowcount
if rows_deleted == 0:
session.rollback()
logger.error(
f"[remove_provider_credential] Failed to delete credential | organization_id {org_id}, provider {provider}, project_id {project_id}"
logger.warning(
f"[remove_provider_credential] Credential not found | organization_id {org_id}, provider {provider}, project_id {project_id}"
)
raise HTTPException(
status_code=500,
detail="Failed to delete provider credential",
status_code=404, detail=f"No credentials found for provider '{provider}'."
)
session.commit()
logger.info(
Expand Down Expand Up @@ -318,6 +322,16 @@ def remove_creds_for_org(*, session: Session, org_id: int, project_id: int) -> N

rows_deleted = result.rowcount

if rows_deleted == 0:
session.rollback()
logger.warning(
f"[remove_creds_for_org] No credentials to delete | organization_id {org_id}, project_id {project_id}"
)
raise HTTPException(
status_code=404,
detail="No credentials found for this organization and project.",
)

if rows_deleted < expected_count:
logger.error(
f"[remove_creds_for_org] Failed to delete all credentials | organization_id {org_id}, project_id {project_id}, expected {expected_count}, deleted {rows_deleted}"
Expand Down
2 changes: 1 addition & 1 deletion backend/app/crud/document/doc_transformation_job.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from uuid import UUID
from typing import List, Optional

from fastapi import HTTPException
from sqlmodel import Session, select, and_

from app.crud import DocumentCrud
Expand All @@ -13,7 +14,6 @@
)
from app.models.document import Document
from app.core.util import now
from app.core.exception_handlers import HTTPException

logger = logging.getLogger(__name__)

Expand Down
2 changes: 1 addition & 1 deletion backend/app/crud/document/document.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import logging
from uuid import UUID

from fastapi import HTTPException
from sqlmodel import Session, select, and_

from app.models import Document
from app.core.util import now
from app.core.exception_handlers import HTTPException

logger = logging.getLogger(__name__)

Expand Down
Loading
Loading