diff --git a/.github/workflows/image-scan.yml b/.github/workflows/image-scan.yml index 6f00329..bd10c0c 100644 --- a/.github/workflows/image-scan.yml +++ b/.github/workflows/image-scan.yml @@ -28,7 +28,7 @@ jobs: # ── Trivy scan → SARIF ─────────────────────────────────────────────────── - name: Run Trivy vulnerability scanner - uses: aquasecurity/trivy-action@0.28.0 + uses: aquasecurity/trivy-action@v0.28.0 with: image-ref: apexchainx-backend:scan format: sarif diff --git a/.github/workflows/semgrep.yml b/.github/workflows/semgrep.yml index 903fe52..85efe2b 100644 --- a/.github/workflows/semgrep.yml +++ b/.github/workflows/semgrep.yml @@ -34,7 +34,7 @@ jobs: # p/owasp-top-ten – OWASP Top 10 coverage - name: Run Semgrep run: | - semgrep ci \ + semgrep scan \ --config p/security-audit \ --config p/python \ --config p/owasp-top-ten \ diff --git a/app/api/exception_handlers.py b/app/api/exception_handlers.py index 849fea0..22a8ff7 100644 --- a/app/api/exception_handlers.py +++ b/app/api/exception_handlers.py @@ -19,7 +19,7 @@ from pydantic import BaseModel, Field from starlette.exceptions import HTTPException as StarletteHTTPException -from app.utils.correlation_ctx import get_correlation_id, get_or_generate_correlation_id +from app.utils.correlation_ctx import get_or_generate_correlation_id class ProblemDetail(BaseModel): @@ -73,9 +73,7 @@ async def http_exception_handler(request: Request, exc: StarletteHTTPException) ) -async def validation_exception_handler( - request: Request, exc: RequestValidationError -) -> JSONResponse: +async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse: """Handle Pydantic validation errors with field-level ``errors[].pointer``.""" errors = [] for e in exc.errors(): diff --git a/app/api/v1/endpoints/api_keys.py b/app/api/v1/endpoints/api_keys.py index 18cbe84..fd42158 100644 --- a/app/api/v1/endpoints/api_keys.py +++ b/app/api/v1/endpoints/api_keys.py @@ -1,12 +1,11 @@ from datetime import datetime -from typing import Optional + from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel from sqlalchemy.orm import Session from app.core.security import require_admin from app.db.session import get_db -from app.core.security import require_admin from app.models.auth import AuthUser from app.services.api_key_store import create_api_key, list_api_keys, revoke_key from app.services.audit_log import audit_log diff --git a/app/api/v1/endpoints/auth.py b/app/api/v1/endpoints/auth.py index 2fba006..6b9bb27 100644 --- a/app/api/v1/endpoints/auth.py +++ b/app/api/v1/endpoints/auth.py @@ -1,7 +1,8 @@ -from fastapi import APIRouter, Header, HTTPException, status, Depends, Request +from fastapi import APIRouter, Depends, Header, HTTPException, Request, status from pydantic import BaseModel, Field from sqlalchemy.orm import Session +from app.core.config import settings from app.core.rate_limiter import rate_limiter from app.core.security import get_current_user, hash_token, require_admin from app.db.session import get_db @@ -17,7 +18,6 @@ SessionInventoryResponse, ) from app.repositories.user_repository import UserRepository, user_orm_to_pydantic -from app.core.config import settings from app.services.auth_store import AuthStore from app.services.credential_stuffing_detector import credential_stuffing_detector from app.services.token_revocation import revoke @@ -385,17 +385,15 @@ def impersonate_user( def _generate_impersonation_token(target_orm, admin_user: AuthUser) -> str: """Generate a short-lived impersonation access token.""" - import time - import hmac - import hashlib import base64 + import hashlib + import hmac import json + import time from app.core.config import settings as app_settings - header = base64.urlsafe_b64encode( - json.dumps({"alg": "HS256", "typ": "JWT"}).encode() - ).rstrip(b"=").decode() + header = base64.urlsafe_b64encode(json.dumps({"alg": "HS256", "typ": "JWT"}).encode()).rstrip(b"=").decode() now = int(time.time()) payload_dict = { @@ -406,9 +404,7 @@ def _generate_impersonation_token(target_orm, admin_user: AuthUser) -> str: "exp": now + 900, # 15 minutes "scope": "impersonate", } - payload = base64.urlsafe_b64encode( - json.dumps(payload_dict).encode() - ).rstrip(b"=").decode() + payload = base64.urlsafe_b64encode(json.dumps(payload_dict).encode()).rstrip(b"=").decode() signing_key = (app_settings.SECRET_KEY or "apexchainx-dev-secret").encode() signature = hmac.new( diff --git a/app/api/v1/endpoints/jobs.py b/app/api/v1/endpoints/jobs.py index 6f7b042..22255e7 100644 --- a/app/api/v1/endpoints/jobs.py +++ b/app/api/v1/endpoints/jobs.py @@ -1,6 +1,5 @@ import json -from datetime import datetime, timezone -from typing import List, Optional +from datetime import UTC, datetime from uuid import UUID from celery.result import AsyncResult @@ -15,7 +14,7 @@ from app.services.job_cleanup import JobCleanupService from app.services.metrics import increment_counter, timer from app.tasks.celery_app import celery_app -from app.tasks.sla_tasks import enqueue_sla_computation, enqueue_bulk_sla_computation +from app.tasks.sla_tasks import enqueue_bulk_sla_computation, enqueue_sla_computation from app.utils.correlation_ctx import get_correlation_id from app.utils.logging import get_structured_logger @@ -74,8 +73,8 @@ class JobRetentionStatsResponse(BaseModel): class JobCleanupRequest(BaseModel): """Request parameters for job cleanup.""" - successful_retention_days: Optional[int] = None - failed_retention_days: Optional[int] = None + successful_retention_days: int | None = None + failed_retention_days: int | None = None dry_run: bool = False @@ -429,7 +428,7 @@ def retry_job( # Increment retry count and update status job.retry_count += 1 - job.last_retried_at = datetime.now(timezone.utc) + job.last_retried_at = datetime.now(UTC) job.error = None # Clear previous error job.status = JobStatus.PENDING job.progress = 0.0 @@ -503,10 +502,12 @@ def retry_job( except (ValueError, KeyError, TypeError) as e: db.rollback() - logger.error("Failed to retry job due to data issue", job_id=str(job.id), error=str(e), correlation_id=correlation_id) + logger.error( + "Failed to retry job due to data issue", job_id=str(job.id), error=str(e), correlation_id=correlation_id + ) raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Failed to retry job due to invalid payload: {str(e)}", + detail=f"Failed to retry job due to invalid payload: {e!s}", ) except Exception as e: db.rollback() diff --git a/app/api/v1/endpoints/metrics.py b/app/api/v1/endpoints/metrics.py index 885a734..6cb3fe0 100644 --- a/app/api/v1/endpoints/metrics.py +++ b/app/api/v1/endpoints/metrics.py @@ -1,7 +1,9 @@ -from datetime import datetime, timezone -from fastapi import APIRouter, Response, Depends -from app.services.metrics import metrics, _DEFAULT_LATENCY_BUCKETS +from datetime import UTC, datetime + +from fastapi import APIRouter, Depends, Response + from app.core.security import require_engineer +from app.services.metrics import _DEFAULT_LATENCY_BUCKETS, metrics router = APIRouter(prefix="/metrics", tags=["Metrics"]) @@ -91,12 +93,8 @@ def get_prometheus_metrics(current_user=Depends(require_engineer)): cumulative = 0 for bucket_bound in sorted(buckets.keys()): cumulative += buckets[bucket_bound] - prometheus_lines.append( - f'{metric_name}_bucket{{{base_labels}le="{bucket_bound}"}} {cumulative}' - ) - prometheus_lines.append( - f'{metric_name}_bucket{{{base_labels}le="+Inf"}} {stats["count"]}' - ) + prometheus_lines.append(f'{metric_name}_bucket{{{base_labels}le="{bucket_bound}"}} {cumulative}') + prometheus_lines.append(f'{metric_name}_bucket{{{base_labels}le="+Inf"}} {stats["count"]}') # ── Timers (exported as histograms with percentile estimation) ─────── for key, stats in metrics_data["timers"].items(): @@ -128,18 +126,14 @@ def get_prometheus_metrics(current_user=Depends(require_engineer)): else: count = stats["count"] - prometheus_lines.append( - f'{metric_name}_seconds_bucket{{{base_labels}le="{bucket}"}} {count}' - ) + prometheus_lines.append(f'{metric_name}_seconds_bucket{{{base_labels}le="{bucket}"}} {count}') - prometheus_lines.append( - f'{metric_name}_seconds_bucket{{{base_labels}le="+Inf"}} {stats["count"]}' - ) + prometheus_lines.append(f'{metric_name}_seconds_bucket{{{base_labels}le="+Inf"}} {stats["count"]}') # ── Process metadata ────────────────────────────────────────────────── prometheus_lines.append("# HELP app_metrics_timestamp Timestamp of metrics collection") prometheus_lines.append("# TYPE app_metrics_timestamp gauge") - prometheus_lines.append(f"app_metrics_timestamp {datetime.now(timezone.utc).timestamp()}") + prometheus_lines.append(f"app_metrics_timestamp {datetime.now(UTC).timestamp()}") return Response( content="\n".join(prometheus_lines) + "\n", diff --git a/app/api/v1/endpoints/oauth.py b/app/api/v1/endpoints/oauth.py index 160005f..93dd0a7 100644 --- a/app/api/v1/endpoints/oauth.py +++ b/app/api/v1/endpoints/oauth.py @@ -1,9 +1,9 @@ """OAuth 2.0 authorization endpoints with PKCE and exact-match redirect_uri validation.""" -from typing import Optional - import secrets + from fastapi import APIRouter, HTTPException, Query + from app.core.config import settings from app.services.audit_log import audit_log from app.services.oauth_session import oauth_state_repo @@ -31,8 +31,8 @@ def authorize(provider: str, redirect_uri: str = Query(...), code_challenge: str def callback( provider: str, state: str = Query(...), - code: Optional[str] = Query(None), - code_verifier: Optional[str] = Query(None), + code: str | None = Query(None), + code_verifier: str | None = Query(None), ): if provider not in PROVIDERS: raise HTTPException(status_code=400, detail=f"Unsupported provider: {provider}") diff --git a/app/api/v1/endpoints/outages.py b/app/api/v1/endpoints/outages.py index eedd354..2cb5fa6 100644 --- a/app/api/v1/endpoints/outages.py +++ b/app/api/v1/endpoints/outages.py @@ -83,9 +83,13 @@ def list_outages( search: str | None = None, start_date: datetime | None = None, end_date: datetime | None = None, - page: int = Query(default=1, ge=1, description="Page number (offset pagination). Not used when cursor is provided."), + page: int = Query( + default=1, ge=1, description="Page number (offset pagination). Not used when cursor is provided." + ), page_size: int = Query(default=20, ge=1, le=100, description="Items per page."), - cursor: str | None = Query(default=None, description="Cursor for cursor-based pagination. Overrides page/page_size."), + cursor: str | None = Query( + default=None, description="Cursor for cursor-based pagination. Overrides page/page_size." + ), limit: int = Query(default=20, ge=1, le=100, description="Limit for cursor-based pagination (used with cursor)."), sort_by: OutageSortField = Query( default=OutageSortField.detected_at, diff --git a/app/api/v1/endpoints/payments.py b/app/api/v1/endpoints/payments.py index 4a4552c..571f5fc 100644 --- a/app/api/v1/endpoints/payments.py +++ b/app/api/v1/endpoints/payments.py @@ -14,7 +14,6 @@ from app.models.payment import PaginatedPayments, PaymentTransaction, PaymentTransitionError from app.repositories.payment_repository import PaymentRepository from app.services.audit_log import audit_log -from app.core.security import require_admin, require_engineer router = APIRouter() @@ -61,9 +60,13 @@ class ReconciliationHistoryResponse(BaseModel): @router.get("/") def list_payments( - page: int = Query(default=1, ge=1, description="Page number (offset pagination). Not used when cursor is provided."), + page: int = Query( + default=1, ge=1, description="Page number (offset pagination). Not used when cursor is provided." + ), page_size: int = Query(default=20, ge=1, le=100, description="Items per page."), - cursor: str | None = Query(default=None, description="Cursor for cursor-based pagination. Overrides page/page_size."), + cursor: str | None = Query( + default=None, description="Cursor for cursor-based pagination. Overrides page/page_size." + ), limit: int = Query(default=20, ge=1, le=100, description="Limit for cursor-based pagination (used with cursor)."), status: str | None = None, type: str | None = None, diff --git a/app/api/v1/endpoints/sla.py b/app/api/v1/endpoints/sla.py index 9168f50..1f82062 100644 --- a/app/api/v1/endpoints/sla.py +++ b/app/api/v1/endpoints/sla.py @@ -1,4 +1,4 @@ -from datetime import datetime, timezone +from datetime import UTC, datetime from fastapi import APIRouter, Depends, HTTPException, Query, Response from sqlalchemy.orm import Session @@ -26,13 +26,13 @@ publish_config_for_severity, update_config_for_severity, ) -from app.utils.cache import TTLCache from app.utils.analytics_exporter import ( export_analytics_summary, export_dashboard_kpi, export_performance_aggregation, export_trends, ) +from app.utils.cache import TTLCache router = APIRouter() @@ -220,9 +220,9 @@ def aggregate_sla_performance( """Get SLA performance aggregation with optional date range filtering (BE-009).""" resolved_site = site_id or site if start_date and start_date.tzinfo is not None: - start_date = start_date.astimezone(timezone.utc).replace(tzinfo=None) + start_date = start_date.astimezone(UTC).replace(tzinfo=None) if end_date and end_date.tzinfo is not None: - end_date = end_date.astimezone(timezone.utc).replace(tzinfo=None) + end_date = end_date.astimezone(UTC).replace(tzinfo=None) if start_date and end_date and start_date > end_date: raise HTTPException(status_code=400, detail="start_date cannot be after end_date") @@ -384,9 +384,9 @@ def export_performance_aggregation_endpoint( """Export performance aggregation data in JSON or CSV format.""" resolved_site = site_id or site if start_date and start_date.tzinfo is not None: - start_date = start_date.astimezone(timezone.utc).replace(tzinfo=None) + start_date = start_date.astimezone(UTC).replace(tzinfo=None) if end_date and end_date.tzinfo is not None: - end_date = end_date.astimezone(timezone.utc).replace(tzinfo=None) + end_date = end_date.astimezone(UTC).replace(tzinfo=None) if start_date and end_date and start_date > end_date: raise HTTPException(status_code=400, detail="start_date cannot be after end_date") diff --git a/app/api/v1/endpoints/sla_dispute.py b/app/api/v1/endpoints/sla_dispute.py index 5d10e8f..69d964f 100644 --- a/app/api/v1/endpoints/sla_dispute.py +++ b/app/api/v1/endpoints/sla_dispute.py @@ -1,6 +1,5 @@ -from datetime import datetime, timezone import json -from datetime import datetime +from datetime import UTC, datetime from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.orm import Session @@ -9,7 +8,6 @@ from app.db.session import get_db from app.models.orm.sla import SLAResultORM from app.models.sla_dispute import DisputeAuditLog, DisputeStatus, SLADispute -from app.repositories.sla_repository import SLARepository from app.schemas.sla_dispute import ( CreateProposedSLARequest, DisputeAuditLogResponse, @@ -17,10 +15,9 @@ DisputeResolveRequest, DisputeResponse, ) -from app.core.security import require_engineer, require_admin from app.services.metrics import ( - increment_counter, SLADISPUTE_NOTIFICATION_ATTEMPT_TOTAL, + increment_counter, ) from app.services.sla.sla_calculator import SLACalculator @@ -207,7 +204,7 @@ def resolve_dispute( dispute.status = payload.status dispute.resolved_by = payload.resolved_by dispute.resolution_notes = payload.resolution_notes - dispute.resolved_at = datetime.now(timezone.utc) + dispute.resolved_at = datetime.now(UTC) # If resolving and apply_proposed is true, mark the proposed SLA as latest if payload.status == DisputeStatus.RESOLVED and payload.apply_proposed: diff --git a/app/api/v1/endpoints/wallets.py b/app/api/v1/endpoints/wallets.py index d7c1e40..4128fee 100644 --- a/app/api/v1/endpoints/wallets.py +++ b/app/api/v1/endpoints/wallets.py @@ -3,9 +3,11 @@ All handlers now pass a request-scoped database session to WalletRegistry. """ -from fastapi import APIRouter, Depends, HTTPException, Query, status +from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session +from app.core.exceptions import ApexConflictError +from app.core.security import require_engineer from app.db.session import get_db from app.models.wallet import ( Wallet, @@ -17,8 +19,6 @@ WalletStatusResponse, WalletTrustlineResponse, ) -from app.core.exceptions import ApexConflictError -from app.core.security import require_engineer from app.services.wallet_registry import WalletRegistry router = APIRouter() diff --git a/app/api/v1/endpoints/webhooks.py b/app/api/v1/endpoints/webhooks.py index 651fffe..409316f 100644 --- a/app/api/v1/endpoints/webhooks.py +++ b/app/api/v1/endpoints/webhooks.py @@ -1,23 +1,20 @@ import json import secrets -from datetime import datetime, timedelta, timezone -from typing import List, Optional +from datetime import UTC, datetime, timedelta from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel, ConfigDict, HttpUrl, field_validator -from sqlalchemy import cast, or_, String +from sqlalchemy import String, cast, or_ from sqlalchemy.orm import Session from app.core.config import settings from app.core.security import hash_token, require_admin from app.db.session import get_db from app.models.webhook import Webhook, WebhookDelivery, WebhookDeliveryStatus, WebhookEvent -issue/114-117-webhook-concurrency-canonical-json -from app.services.formatters import canonical_json from app.services.audit_log import audit_log -main +from app.services.formatters import canonical_json from app.services.webhook_service import WEBHOOK_SCHEMA_VERSION from app.utils.network_validation import validate_webhook_url @@ -313,15 +310,13 @@ def delete_webhook(webhook_id: UUID, current_user=Depends(require_admin), db: Se @router.get("/{webhook_id}/deliveries", response_model=PaginatedWebhookDeliveries) def list_webhook_deliveries( webhook_id: UUID, - status: Optional[WebhookDeliveryStatus] = Query(None, description="Filter by delivery status."), - event: Optional[WebhookEvent] = Query(None, description="Filter by delivery event type."), - search: Optional[str] = Query(None, description="Search delivery id, error message, or response status code."), - created_after: Optional[datetime] = Query(None, description="Return deliveries created after this timestamp."), - created_before: Optional[datetime] = Query(None, description="Return deliveries created before this timestamp."), - delivered_after: Optional[datetime] = Query(None, description="Return deliveries delivered after this timestamp."), - delivered_before: Optional[datetime] = Query( - None, description="Return deliveries delivered before this timestamp." - ), + status: WebhookDeliveryStatus | None = Query(None, description="Filter by delivery status."), + event: WebhookEvent | None = Query(None, description="Filter by delivery event type."), + search: str | None = Query(None, description="Search delivery id, error message, or response status code."), + created_after: datetime | None = Query(None, description="Return deliveries created after this timestamp."), + created_before: datetime | None = Query(None, description="Return deliveries created before this timestamp."), + delivered_after: datetime | None = Query(None, description="Return deliveries delivered after this timestamp."), + delivered_before: datetime | None = Query(None, description="Return deliveries delivered before this timestamp."), limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0, description="Number of records to skip"), # BE-083 db: Session = Depends(get_db), @@ -385,7 +380,7 @@ def rotate_webhook_secret(webhook_id: UUID, current_user=Depends(require_admin), # Store old secret in previous_secrets with expiry if webhook.secret: - now = datetime.now(timezone.utc) + now = datetime.now(UTC) expires_at = now + timedelta(hours=settings.WEBHOOK_SECRET_GRACE_HOURS) previous_entry = { "hashed_secret": hash_token(webhook.secret), @@ -400,7 +395,7 @@ def rotate_webhook_secret(webhook_id: UUID, current_user=Depends(require_admin), new_secret = secrets.token_hex(32) webhook.secret = new_secret webhook.secret_version = old_secret_version + 1 - webhook.last_secret_rotation_at = datetime.now(timezone.utc) + webhook.last_secret_rotation_at = datetime.now(UTC) db.commit() @@ -454,7 +449,7 @@ def retry_delivery(webhook_id: UUID, delivery_id: UUID, db: Session = Depends(ge # BE-086: Dead-letter handling endpoints -@router.get("/{webhook_id}/dead-letter-deliveries", response_model=List[WebhookDeliveryResponse]) +@router.get("/{webhook_id}/dead-letter-deliveries", response_model=list[WebhookDeliveryResponse]) def list_dead_letter_deliveries( webhook_id: UUID, limit: int = Query(50, ge=1, le=200), diff --git a/app/cli/seed.py b/app/cli/seed.py index 074b550..1743806 100644 --- a/app/cli/seed.py +++ b/app/cli/seed.py @@ -13,7 +13,7 @@ import logging import random import sys -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta logger = logging.getLogger(__name__) @@ -72,7 +72,7 @@ def _seed_devices(db, count: int, rng: random.Random) -> int: description="Synthetic outage for dev seeding", severity=rng.choice(severities), status="resolved", - detected_at=datetime.now(timezone.utc) - timedelta(days=rng.randint(0, 90)), + detected_at=datetime.now(UTC) - timedelta(days=rng.randint(0, 90)), ) repo.create_or_get_existing(payload) created += 1 @@ -93,7 +93,7 @@ def _seed_outages(db, count: int, rng: random.Random, device_ids: list[str]) -> for _ in range(count): device_id = rng.choice(device_ids) if device_ids else f"site-{rng.randint(0, 9999):04d}" site_name = device_id.replace("site-", "dev-site-") - detected_at = datetime.now(timezone.utc) - timedelta(days=rng.randint(0, 365)) + detected_at = datetime.now(UTC) - timedelta(days=rng.randint(0, 365)) resolved_at = detected_at + timedelta(minutes=rng.randint(5, 480)) try: payload = OutageCreate( @@ -151,6 +151,7 @@ def _seed_payments(db, count: int, rng: random.Random, device_ids: list[str]) -> def _clear_existing(db) -> dict[str, int]: """Remove existing data for idempotent --force runs. Returns counts cleared.""" from sqlalchemy import func + from app.models.orm.outage import OutageORM counts: dict[str, int] = {} @@ -170,7 +171,7 @@ def main(argv: list[str] | None = None) -> int: logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)-8s %(message)s") - rng = random.Random(args.seed) + rng = random.Random(args.seed) # nosec B311 - deterministic dev seed data logger.info( "Seeding dev DB with seed=%d outages=%d devices=%d payments=%d force=%s", args.seed, diff --git a/app/core/db_retry.py b/app/core/db_retry.py index 9f4cd2f..1bd2cad 100644 --- a/app/core/db_retry.py +++ b/app/core/db_retry.py @@ -1,9 +1,8 @@ """Transient DB error retry policy using tenacity for issue #34.""" -from sqlalchemy.exc import OperationalError, DisconnectionError +from sqlalchemy.exc import DisconnectionError, OperationalError from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential - db_retry_policy = retry( retry=retry_if_exception_type((OperationalError, DisconnectionError)), stop=stop_after_attempt(3), diff --git a/app/core/exceptions.py b/app/core/exceptions.py index bb1ed21..756ea1f 100644 --- a/app/core/exceptions.py +++ b/app/core/exceptions.py @@ -1,12 +1,14 @@ from __future__ import annotations -from typing import Any, Dict, List, Optional +from typing import Any from fastapi import Request from fastapi.responses import JSONResponse from pydantic import ValidationError from sqlalchemy.exc import IntegrityError +from app.utils.correlation_ctx import get_or_generate_correlation_id + class ApexException(Exception): """Base exception for all ApexChainx domain errors. @@ -46,20 +48,18 @@ def __init__(self, detail: str = "A transient error occurred.", **kwargs: Any) - class ApexConflictError(ApexException): - def __init__(self, detail: str, fields: Optional[Dict[str, str]] = None): + def __init__(self, detail: str, fields: dict[str, str] | None = None): super().__init__(detail=detail, error_code="conflict", status_code=409) self.fields = fields or {} -main class ApexValidationError(ApexException): - def __init__(self, detail: str, errors: Optional[List[Dict[str, Any]]] = None): + def __init__(self, detail: str, errors: list[dict[str, Any]] | None = None): super().__init__(detail=detail, error_code="validation_error", status_code=422) self.errors = errors or [] -main -def _extract_integrity_fields(exc: IntegrityError) -> Dict[str, str]: +def _extract_integrity_fields(exc: IntegrityError) -> dict[str, str]: # Use the first arg of exc.orig (the raw psycopg2/driver message) when available; # fall back to str(exc.orig) for other drivers. orig = exc.orig @@ -67,7 +67,7 @@ def _extract_integrity_fields(exc: IntegrityError) -> Dict[str, str]: msg = str(orig.args[0]) else: msg = str(orig) - fields: Dict[str, str] = {} + fields: dict[str, str] = {} if "Key (" in msg: for part in msg.split("Key ")[1:]: if " already exists" in part: @@ -81,11 +81,11 @@ def _build_rfc7807( title: str, status: int, detail: str, - instance: Optional[str] = None, + instance: str | None = None, **extra: Any, -) -> Dict[str, Any]: +) -> dict[str, Any]: correlation_id = extra.pop("correlation_id", None) or get_or_generate_correlation_id() - body: Dict[str, Any] = { + body: dict[str, Any] = { "type": f"https://developer.apexchainx.io/errors/{status}", "title": title, "status": status, @@ -122,11 +122,13 @@ async def pydantic_validation_handler(request: Request, exc: ValidationError) -> errors = [] for err in raw: loc = " -> ".join(str(p) for p in err.get("loc", [])) - errors.append({ - "field": loc, - "message": err.get("msg", ""), - "code": err.get("type", ""), - }) + errors.append( + { + "field": loc, + "message": err.get("msg", ""), + "code": err.get("type", ""), + } + ) body = _build_rfc7807( title="Validation Error", status=422, diff --git a/app/core/lifecycle.py b/app/core/lifecycle.py index f476b7a..3629cdc 100644 --- a/app/core/lifecycle.py +++ b/app/core/lifecycle.py @@ -1,8 +1,8 @@ """Graceful shutdown handler on SIGTERM/SIGINT for issue #31.""" import asyncio -import signal import logging +import signal logger = logging.getLogger(__name__) diff --git a/app/core/lock.py b/app/core/lock.py index badc633..986e4ce 100644 --- a/app/core/lock.py +++ b/app/core/lock.py @@ -9,6 +9,7 @@ import hashlib import logging +from collections.abc import Generator from contextlib import contextmanager from sqlalchemy import text diff --git a/app/core/rate_limiter.py b/app/core/rate_limiter.py index 68461a5..70fda6d 100644 --- a/app/core/rate_limiter.py +++ b/app/core/rate_limiter.py @@ -11,7 +11,7 @@ import random from collections import defaultdict from time import time -from typing import Dict, List +from typing import ClassVar import redis.asyncio as redis from redis.exceptions import RedisError @@ -39,7 +39,7 @@ class SimpleRateLimiter: - _shared: Dict[str, List[float]] = defaultdict(list) + _shared: ClassVar[dict[str, list[float]]] = defaultdict(list) def __init__(self) -> None: self.requests = SimpleRateLimiter._shared @@ -78,7 +78,7 @@ async def _is_allowed_async(self, key: str) -> bool: encoded_key = self._key_namespace(key) now_ts = int(time()) - member = f"{now_ts}-{random.random()}" + member = f"{now_ts}-{random.random()}" # nosec B311 - unique sorted-set member, not security result = await self.client.eval( RATE_LIMITER_LUA, 1, diff --git a/app/core/security.py b/app/core/security.py index e3c1a52..b51d67b 100644 --- a/app/core/security.py +++ b/app/core/security.py @@ -4,18 +4,17 @@ import json import re import time -from datetime import datetime, timezone +from datetime import UTC, datetime from typing import Any from fastapi import Depends, Header, HTTPException from passlib.context import CryptContext from sqlalchemy.orm import Session +from app.core.config import settings as app_settings from app.db.session import get_db from app.models.auth import AuthUser from app.models.enums import Role -from app.core.config import settings as app_settings -from app.db.session import get_db pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") @@ -110,9 +109,9 @@ def _verify_impersonation_token(token: str) -> dict[str, Any] | None: def get_current_user(authorization: str | None = Header(default=None), db: Session = Depends(get_db)) -> AuthUser: + from app.repositories.user_repository import UserRepository, user_orm_to_pydantic from app.services.auth_store import AuthStore from app.services.token_revocation import is_revoked - from app.repositories.user_repository import UserRepository, user_orm_to_pydantic token = _extract_bearer_token(authorization) @@ -170,9 +169,7 @@ def get_current_user_or_service( raise HTTPException(status_code=401, detail="Invalid API key") if key.revoked_at is not None: raise HTTPException(status_code=401, detail="API key has been revoked") - if key.expires_at is not None and key.expires_at.replace(tzinfo=None) < datetime.now(timezone.utc).replace( - tzinfo=None - ): + if key.expires_at is not None and key.expires_at.replace(tzinfo=None) < datetime.now(UTC).replace(tzinfo=None): raise HTTPException(status_code=401, detail="API key has expired") return { "actor_type": "service", diff --git a/app/core/tracing.py b/app/core/tracing.py index 9f61a09..5ff63d1 100644 --- a/app/core/tracing.py +++ b/app/core/tracing.py @@ -12,19 +12,20 @@ import logging import os +from collections.abc import Callable from functools import wraps -from typing import Any, Callable, Optional +from typing import Any from opentelemetry import trace -from opentelemetry.sdk.resources import Resource, SERVICE_NAME, SERVICE_VERSION -from opentelemetry.sdk.trace import TracerProvider, sampling -from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor -from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor from opentelemetry.instrumentation.redis import RedisInstrumentor +from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor from opentelemetry.propagate import set_global_textmap +from opentelemetry.sdk.resources import SERVICE_NAME, SERVICE_VERSION, Resource +from opentelemetry.sdk.trace import TracerProvider, sampling +from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator from app.core.config import settings @@ -174,10 +175,14 @@ def _server_request_hook(span, scope): def _client_response_hook(span, request, response): """Enrich client spans with response metadata.""" if response is not None: - span.set_attribute("http.status_code", response.status_code) + status_code = getattr(response, "status_code", None) + if status_code is None and isinstance(response, dict): + status_code = response.get("status") + if status_code is not None: + span.set_attribute("http.status_code", status_code) -def get_current_traceparent() -> Optional[str]: +def get_current_traceparent() -> str | None: """Get the current trace context as a traceparent header value. Returns: @@ -194,7 +199,7 @@ def get_current_traceparent() -> Optional[str]: return f"00-{span_context.trace_id:032x}-{span_context.span_id:016x}-0{span_context.trace_flags:02x}" -def traced(name: Optional[str] = None, attributes: Optional[dict[str, Any]] = None): +def traced(name: str | None = None, attributes: dict[str, Any] | None = None): """Decorator to trace a function with a span. Usage: diff --git a/app/main.py b/app/main.py index 01ec1a0..fbdfd44 100644 --- a/app/main.py +++ b/app/main.py @@ -1,19 +1,15 @@ -from datetime import datetime, timezone +from datetime import UTC, datetime + from fastapi import FastAPI, Request -issue/114-117-webhook-concurrency-canonical-json from fastapi.exceptions import RequestValidationError -from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse, RedirectResponse -main +from pydantic import ValidationError from redis import ConnectionError, Redis, TimeoutError from sqlalchemy import text from sqlalchemy.exc import IntegrityError -from pydantic import ValidationError -from starlette.middleware.cors import CORSMiddleware, SAFELISTED_HEADERS, ALL_METHODS -from starlette.types import ASGIApp, Receive, Scope, Send - -from fastapi.exceptions import RequestValidationError from starlette.exceptions import HTTPException as StarletteHTTPException +from starlette.middleware.cors import ALL_METHODS, SAFELISTED_HEADERS, CORSMiddleware +from starlette.types import ASGIApp, Receive, Scope, Send from app.api.exception_handlers import ( general_exception_handler, @@ -26,20 +22,20 @@ ApexException, ApexNotFoundError, ApexTransientError, + integrity_error_handler, + pydantic_validation_handler, ) -from app.core.logging_config import configure_logging from app.core.lifecycle import install_signal_handlers +from app.core.logging_config import configure_logging from app.core.tracing import init_tracing, instrument_app -from app.core.exceptions import integrity_error_handler, pydantic_validation_handler from app.db.session import engine -from app.services.health_report import build_readiness_report +from app.middleware.api_version import ApiVersionMiddleware from app.middleware.content_type import ContentTypeMiddleware from app.middleware.correlation import CorrelationMiddleware -from app.middleware.etag import ETagMiddleware from app.middleware.idempotency import IdempotencyMiddleware from app.middleware.security_headers import SecurityHeadersMiddleware -from app.middleware.api_version import ApiVersionMiddleware - +from app.services.health_report import build_readiness_report +from app.utils.correlation_ctx import get_or_generate_correlation_id configure_logging() validate_critical_settings(settings) @@ -148,9 +144,6 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: app.add_middleware(ApiVersionMiddleware) -from app.utils.correlation_ctx import get_or_generate_correlation_id - - @app.exception_handler(ApexException) async def apex_exception_handler(request: Request, exc: ApexException) -> JSONResponse: correlation_id = get_or_generate_correlation_id() @@ -210,13 +203,13 @@ async def apex_transient_error_handler(request: Request, exc: ApexTransientError # Health checks @app.get("/health/liveness") def liveness(): - return {"status": "ok", "timestamp": datetime.now(timezone.utc).isoformat()} + return {"status": "ok", "timestamp": datetime.now(UTC).isoformat()} @app.get("/health/readiness") async def readiness(): report = build_readiness_report(engine, settings.CELERY_BROKER_URL) - report["timestamp"] = datetime.now(timezone.utc).isoformat() + report["timestamp"] = datetime.now(UTC).isoformat() return report @@ -229,6 +222,7 @@ def health_check(): headers={"Deprecation": "true"}, ) + # Register RFC 7807 exception handlers app.add_exception_handler(StarletteHTTPException, http_exception_handler) app.add_exception_handler(RequestValidationError, validation_exception_handler) diff --git a/app/middleware/content_type.py b/app/middleware/content_type.py index bb50c76..0d33531 100644 --- a/app/middleware/content_type.py +++ b/app/middleware/content_type.py @@ -2,10 +2,8 @@ from starlette.middleware.base import BaseHTTPMiddleware from starlette.responses import JSONResponse - from app.utils.correlation_ctx import get_or_generate_correlation_id - ALLOWED_CONTENT_TYPES = frozenset( { "application/json", diff --git a/app/middleware/correlation.py b/app/middleware/correlation.py index f666736..101a35d 100644 --- a/app/middleware/correlation.py +++ b/app/middleware/correlation.py @@ -1,10 +1,11 @@ import hashlib import time + from fastapi import Request +from app.core.exceptions import ApexException from app.utils.correlation_ctx import get_or_generate_correlation_id, set_correlation_id from app.utils.logging import get_structured_logger -from app.core.exceptions import ApexException logger = get_structured_logger("access") @@ -14,6 +15,7 @@ def _hash_value(value: str | None) -> str | None: return None return hashlib.sha256(value.encode("utf-8")).hexdigest()[:16] + # Add per-user API rate limits using Redis sliding-window counter class CorrelationMiddleware: """ASGI-native middleware to add correlation IDs to requests and enable request tracing. diff --git a/app/models/__init__.py b/app/models/__init__.py index b6dac99..0ff997f 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -1,5 +1,9 @@ -from .outage import Outage as Outage, Location as Location, SLAStatus as SLAStatus -from .sla import SLAResult as SLAResult +from .outage import Location as Location +from .outage import Outage as Outage +from .outage import SLAStatus as SLAStatus +from .outage_dto import BulkOutageCreate as BulkOutageCreate +from .outage_dto import OutageCreate as OutageCreate +from .outage_dto import OutageUpdate as OutageUpdate from .payment import PaymentTransaction as PaymentTransaction +from .sla import SLAResult as SLAResult from .wallet import Wallet as Wallet -from .outage_dto import BulkOutageCreate as BulkOutageCreate, OutageCreate as OutageCreate, OutageUpdate as OutageUpdate diff --git a/app/models/auth.py b/app/models/auth.py index cd05b41..8e13d9b 100644 --- a/app/models/auth.py +++ b/app/models/auth.py @@ -1,5 +1,4 @@ from datetime import datetime -from typing import Optional from pydantic import BaseModel, ConfigDict, Field @@ -99,5 +98,5 @@ class LogoutAllSessionsResponse(BaseModel): class ProfileUpdateRequest(BaseModel): """Allowed mutable profile fields. Role and email changes are not permitted here.""" - full_name: Optional[str] = Field(default=None, min_length=1, max_length=255) - stellar_wallet: Optional[str] = Field(default=None, max_length=255) + full_name: str | None = Field(default=None, min_length=1, max_length=255) + stellar_wallet: str | None = Field(default=None, max_length=255) diff --git a/app/models/job.py b/app/models/job.py index 2c0a1c9..4e214fd 100644 --- a/app/models/job.py +++ b/app/models/job.py @@ -1,7 +1,9 @@ import enum import uuid -from datetime import datetime, timezone -from sqlalchemy import Column, Integer, String, DateTime, Text, Enum as SAEnum, Float, JSON +from datetime import UTC, datetime + +from sqlalchemy import JSON, Column, DateTime, Float, Integer, String, Text +from sqlalchemy import Enum as SAEnum from sqlalchemy.dialects.postgresql import UUID from app.db.base_class import Base @@ -41,5 +43,5 @@ class Job(Base): last_retried_at = Column(DateTime, nullable=True) # When the job was last retried started_at = Column(DateTime, nullable=True) finished_at = Column(DateTime, nullable=True) - created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False) - updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False) + created_at = Column(DateTime, default=lambda: datetime.now(UTC), nullable=False) + updated_at = Column(DateTime, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC), nullable=False) diff --git a/app/models/orm/__init__.py b/app/models/orm/__init__.py index a4153a0..8c9923c 100644 --- a/app/models/orm/__init__.py +++ b/app/models/orm/__init__.py @@ -4,16 +4,18 @@ from app.models.orm.session import SessionORM from app.models.orm.sla import SLAResultORM from app.models.orm.token_family import TokenFamilyORM +from app.models.orm.user import UserORM from app.models.orm.wallet import WalletORM from app.models.sla_dispute import SLADispute __all__ = [ "AuditLogORM", - "TokenFamilyORM", - "WalletORM", + "OutageORM", + "PaymentTransactionORM", "SLADispute", "SLAResultORM", "SessionORM", "TokenFamilyORM", "UserORM", + "WalletORM", ] diff --git a/app/models/orm/outage_event.py b/app/models/orm/outage_event.py index 116f8a7..3559084 100644 --- a/app/models/orm/outage_event.py +++ b/app/models/orm/outage_event.py @@ -1,4 +1,4 @@ -from datetime import datetime, timezone +from datetime import UTC, datetime from sqlalchemy import Column, DateTime, ForeignKey, String, Text @@ -17,4 +17,4 @@ class OutageEventORM(Base): schema_version = Column( String(10), nullable=False, default=CURRENT_SCHEMA_VERSION, server_default=CURRENT_SCHEMA_VERSION ) - occurred_at = Column(DateTime(timezone=True), nullable=False, default=datetime.now(timezone.utc)) + occurred_at = Column(DateTime(timezone=True), nullable=False, default=datetime.now(UTC)) diff --git a/app/models/orm/payment.py b/app/models/orm/payment.py index 22b915d..d2befb8 100644 --- a/app/models/orm/payment.py +++ b/app/models/orm/payment.py @@ -1,4 +1,4 @@ -from datetime import datetime, timezone +from datetime import UTC, datetime from sqlalchemy import Column, DateTime, Float, ForeignKey, Integer, String @@ -20,7 +20,7 @@ class PaymentTransactionORM(Base): sla_result_id = Column( Integer, ForeignKey("sla_results.id", ondelete="SET NULL"), nullable=True, index=True, unique=True ) - created_at = Column(DateTime(timezone=True), nullable=False, default=datetime.now(timezone.utc)) + created_at = Column(DateTime(timezone=True), nullable=False, default=datetime.now(UTC)) confirmed_at = Column(DateTime(timezone=True), nullable=True) retry_count = Column(Integer, nullable=False, default=0) last_retried_at = Column(DateTime(timezone=True), nullable=True) diff --git a/app/models/orm/sla.py b/app/models/orm/sla.py index 1d38fc2..26c038b 100644 --- a/app/models/orm/sla.py +++ b/app/models/orm/sla.py @@ -27,7 +27,7 @@ class SLAResultORM(Base): String(64), nullable=True ) # SHA-256 hash of (outage_id || started_at || resolved_at || policy_version) for idempotent recompute (#35) - disputes = relationship("SLADispute", back_populates="sla_result") + disputes = relationship("SLADispute", back_populates="sla_result", foreign_keys="SLADispute.sla_result_id") __table_args__ = ( Index("ix_sla_results_outage_latest", "outage_id", "is_latest"), diff --git a/app/models/orm/token_family.py b/app/models/orm/token_family.py index 7398a25..f9a678a 100644 --- a/app/models/orm/token_family.py +++ b/app/models/orm/token_family.py @@ -1,4 +1,4 @@ -from datetime import datetime, timezone +from datetime import UTC, datetime from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String @@ -12,7 +12,5 @@ class TokenFamilyORM(Base): email = Column(String(255), ForeignKey("users.email"), nullable=False, index=True) current_sequence = Column(Integer, nullable=False, default=0) compromised = Column(Boolean, nullable=False, default=False) - created_at = Column(DateTime(timezone=True), nullable=False, default=datetime.now(timezone.utc)) - updated_at = Column( - DateTime(timezone=True), nullable=False, default=datetime.now(timezone.utc), onupdate=datetime.now(timezone.utc) - ) + created_at = Column(DateTime(timezone=True), nullable=False, default=datetime.now(UTC)) + updated_at = Column(DateTime(timezone=True), nullable=False, default=datetime.now(UTC), onupdate=datetime.now(UTC)) diff --git a/app/models/orm/wallet.py b/app/models/orm/wallet.py index 9faba4b..4f0a30c 100644 --- a/app/models/orm/wallet.py +++ b/app/models/orm/wallet.py @@ -4,7 +4,7 @@ Uses the same Column-based declarative style as the rest of the codebase. """ -from datetime import datetime, UTC +from datetime import UTC, datetime from sqlalchemy import Boolean, Column, DateTime, Integer, String diff --git a/app/models/outage_dto.py b/app/models/outage_dto.py index 78fbe4a..14ff48b 100644 --- a/app/models/outage_dto.py +++ b/app/models/outage_dto.py @@ -1,6 +1,5 @@ -from datetime import datetime, timezone +from datetime import UTC, datetime from enum import Enum -from typing import Optional from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator @@ -85,8 +84,8 @@ def validate_detected_at_timezone(cls, v: datetime) -> datetime: if v.tzinfo is None: raise ValidationError("detected_at must be timezone-aware") # Normalize to UTC - if v.tzinfo != timezone.utc: - v = v.astimezone(timezone.utc) + if v.tzinfo != UTC: + v = v.astimezone(UTC) return v @@ -147,8 +146,8 @@ def validate_bulk_count(cls, v: list[OutageCreate]) -> list[OutageCreate]: class ImportFieldError(BaseModel): """A single field-level validation error within an import row.""" - field: Optional[str] = None - type: Optional[str] = None + field: str | None = None + type: str | None = None message: str diff --git a/app/models/outage_event.py b/app/models/outage_event.py index 45395b0..0bbe07c 100644 --- a/app/models/outage_event.py +++ b/app/models/outage_event.py @@ -1,7 +1,7 @@ from __future__ import annotations from datetime import datetime -from typing import Any, Dict, Literal, Optional, Union +from typing import Any, Literal, Union from pydantic import BaseModel, Field diff --git a/app/models/sla.py b/app/models/sla.py index a73bd55..64a9358 100644 --- a/app/models/sla.py +++ b/app/models/sla.py @@ -1,4 +1,4 @@ -from typing import Any, List, Literal, Optional +from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field @@ -74,9 +74,9 @@ class SLAResult(BaseModel): rating: Literal["exceptional", "excellent", "good", "poor"] policy_version: str = Field(..., description="Version of SLA policy used for this calculation") threshold_source: str = Field(..., description="Source of threshold values (e.g., 'config', 'contract')") - reason_code: Optional[str] = Field(None, description="Machine-readable reason code for the decision") - decision_trace: Optional[str] = Field(None, description="Machine-readable decision trace for audit") - compute_hash: Optional[str] = Field(None, description="SHA-256 hash of inputs for idempotent recompute (#35)") + reason_code: str | None = Field(None, description="Machine-readable reason code for the decision") + decision_trace: str | None = Field(None, description="Machine-readable decision trace for audit") + compute_hash: str | None = Field(None, description="SHA-256 hash of inputs for idempotent recompute (#35)") class SLASeverityConfig(BaseModel): @@ -99,7 +99,7 @@ class SLAConfigHistoryEntry(BaseModel): reward_base: int content_hash: str published_at: str - published_by: Optional[str] = None + published_by: str | None = None class SLAPerformanceAggregation(BaseModel): diff --git a/app/models/sla_dispute.py b/app/models/sla_dispute.py index 21c5237..29bd96d 100644 --- a/app/models/sla_dispute.py +++ b/app/models/sla_dispute.py @@ -1,5 +1,5 @@ import uuid -from datetime import datetime, timezone +from datetime import UTC, datetime from enum import Enum as PyEnum from sqlalchemy import Column, DateTime, Enum, ForeignKey, Integer, String, Text @@ -26,7 +26,7 @@ class SLADispute(Base): # Dispute metadata flagged_by = Column(String(255), nullable=False) dispute_reason = Column(Text, nullable=False) - flagged_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False) + flagged_at = Column(DateTime, default=lambda: datetime.now(UTC), nullable=False) # Resolution metadata status = Column(Enum(DisputeStatus), default=DisputeStatus.PENDING, nullable=False) @@ -48,6 +48,6 @@ class DisputeAuditLog(Base): action = Column(String(50), nullable=False) # e.g. "flagged", "resolved", "rejected" actor = Column(String(255), nullable=False) notes = Column(Text, nullable=True) - recorded_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False) + recorded_at = Column(DateTime, default=lambda: datetime.now(UTC), nullable=False) dispute = relationship("SLADispute", back_populates="audit_logs") diff --git a/app/models/wallet.py b/app/models/wallet.py index de42201..bb1827c 100644 --- a/app/models/wallet.py +++ b/app/models/wallet.py @@ -3,7 +3,6 @@ from pydantic import BaseModel, Field, field_validator - _STELLAR_PUBLIC_KEY_RE = re.compile(r"^G[A-Z2-7]{55}$") diff --git a/app/models/webhook.py b/app/models/webhook.py index 165f1e4..4684abb 100644 --- a/app/models/webhook.py +++ b/app/models/webhook.py @@ -1,7 +1,9 @@ import enum import uuid -from datetime import datetime, timezone -from sqlalchemy import Column, String, Boolean, DateTime, Text, Integer, ForeignKey, Enum as SAEnum +from datetime import UTC, datetime + +from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text +from sqlalchemy import Enum as SAEnum from sqlalchemy.dialects.postgresql import JSONB, UUID from sqlalchemy.orm import relationship @@ -34,8 +36,8 @@ class Webhook(Base): events = Column(Text, nullable=False) # JSON-encoded list of WebhookEvent values resolved_ips = Column(Text, nullable=True) # JSON-encoded list of resolved IPs for SSRF validation max_retries = Column(Integer, default=3, nullable=False) - created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False) - updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False) + created_at = Column(DateTime, default=lambda: datetime.now(UTC), nullable=False) + updated_at = Column(DateTime, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC), nullable=False) # BE-034: Secret lifecycle metadata last_secret_rotation_at = Column(DateTime, nullable=True) # When the secret was last rotated @@ -63,7 +65,7 @@ class WebhookDelivery(Base): delivered_at = Column(DateTime, nullable=True) dead_lettered_at = Column(DateTime, nullable=True) # BE-086: When delivery was marked as dead-letter signature_version = Column(Integer, default=1, nullable=False) # BE-087: Explicit signature algorithm version - created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False) - updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False) + created_at = Column(DateTime, default=lambda: datetime.now(UTC), nullable=False) + updated_at = Column(DateTime, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC), nullable=False) webhook = relationship("Webhook", back_populates="deliveries") diff --git a/app/repositories/outage_event_repository.py b/app/repositories/outage_event_repository.py index 1db6457..f5842df 100644 --- a/app/repositories/outage_event_repository.py +++ b/app/repositories/outage_event_repository.py @@ -1,6 +1,6 @@ import json -from datetime import datetime, timezone -from typing import Any, Dict, Optional +from datetime import UTC, datetime +from typing import Any from uuid import uuid4 from sqlalchemy.orm import Session @@ -21,15 +21,14 @@ def record(self, outage_id: str, event_type: str, detail: dict[str, Any] | None event_type=event_type, detail=json.dumps(detail) if detail else None, schema_version=CURRENT_SCHEMA_VERSION, - occurred_at=datetime.now(timezone.utc), + occurred_at=datetime.now(UTC), ) self.db.add(orm) self.db.commit() self.db.refresh(orm) return orm - -# Add bulk Payments state-transition endpoint with all-or-nothing semantics + # Add bulk Payments state-transition endpoint with all-or-nothing semantics def list_for_outage( self, outage_id: str, @@ -38,7 +37,7 @@ def list_for_outage( end_date: datetime | None = None, page: int = 1, page_size: int = 20, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: query = self.db.query(OutageEventORM).filter(OutageEventORM.outage_id == outage_id) if event_type: query = query.filter(OutageEventORM.event_type == event_type) diff --git a/app/repositories/payment_repository.py b/app/repositories/payment_repository.py index b11c7a0..b7f2de6 100644 --- a/app/repositories/payment_repository.py +++ b/app/repositories/payment_repository.py @@ -1,9 +1,7 @@ import builtins -from datetime import datetime, timezone -from typing import List, Optional +from datetime import UTC, datetime from uuid import uuid4 -from sqlalchemy import and_, or_ from sqlalchemy.orm import Session from app.core.config import settings @@ -11,7 +9,6 @@ from app.models.orm.payment import PaymentTransactionORM from app.models.payment import PaymentTransaction, validate_transition from app.models.sla import SLAResult -from app.utils.cursor import CursorPage, decode_cursor, encode_cursor def _orm_to_pydantic(orm: PaymentTransactionORM) -> PaymentTransaction: @@ -57,13 +54,13 @@ def create(self, data: PaymentTransaction) -> PaymentTransaction: self.db.refresh(orm) return _orm_to_pydantic(orm) - def get(self, transaction_id: str) -> Optional[PaymentTransaction]: + def get(self, transaction_id: str) -> PaymentTransaction | None: orm = self.db.query(PaymentTransactionORM).filter(PaymentTransactionORM.id == transaction_id).first() if not orm: return None return _orm_to_pydantic(orm) - def get_by_sla_result(self, sla_result_id: int, for_update: bool = False) -> Optional[PaymentTransaction]: + def get_by_sla_result(self, sla_result_id: int, for_update: bool = False) -> PaymentTransaction | None: query = self.db.query(PaymentTransactionORM).filter(PaymentTransactionORM.sla_result_id == sla_result_id) if for_update: query = query.with_for_update() @@ -104,11 +101,11 @@ def list( ) return [_orm_to_pydantic(r) for r in rows], total - def list_by_outage(self, outage_id: str) -> List[PaymentTransaction]: + def list_by_outage(self, outage_id: str) -> builtins.list[PaymentTransaction]: rows = self.db.query(PaymentTransactionORM).filter(PaymentTransactionORM.outage_id == outage_id).all() return [_orm_to_pydantic(r) for r in rows] - def update_status(self, transaction_id: str, status: str) -> Optional[PaymentTransaction]: + def update_status(self, transaction_id: str, status: str) -> PaymentTransaction | None: orm = self.db.query(PaymentTransactionORM).filter(PaymentTransactionORM.id == transaction_id).first() if not orm: return None @@ -137,7 +134,7 @@ def create_for_sla_result(self, outage_id: str, sla_result: SLAResult) -> Paymen status="pending", outage_id=outage_id, sla_result_id=sla_result.id, - created_at=datetime.now(timezone.utc), + created_at=datetime.now(UTC), confirmed_at=None, ) return self.create(transaction) @@ -152,7 +149,7 @@ def reconcile(self, transaction_id: str, new_status: str) -> PaymentTransaction validate_transition(orm.status, new_status) orm.status = new_status if new_status == "confirmed": - orm.confirmed_at = datetime.now(timezone.utc) + orm.confirmed_at = datetime.now(UTC) self.db.commit() self.db.refresh(orm) return _orm_to_pydantic(orm) @@ -166,7 +163,7 @@ def retry(self, transaction_id: str) -> PaymentTransaction | None: return None # caller should raise 409 validate_transition(orm.status, "pending") orm.retry_count += 1 - orm.last_retried_at = datetime.now(timezone.utc) + orm.last_retried_at = datetime.now(UTC) orm.status = "pending" self.db.commit() self.db.refresh(orm) diff --git a/app/repositories/sla_repository.py b/app/repositories/sla_repository.py index 7088a9a..15d4f63 100644 --- a/app/repositories/sla_repository.py +++ b/app/repositories/sla_repository.py @@ -1,8 +1,8 @@ from __future__ import annotations from collections.abc import Mapping -from datetime import datetime, timezone -from typing import Literal, Optional +from datetime import UTC, datetime +from typing import Literal from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from sqlalchemy import case, func, select, update @@ -39,7 +39,7 @@ class SLARepository: def __init__(self, db: Session): self.db = db - def find_by_compute_hash(self, outage_id: str, compute_hash: str) -> Optional[SLAResultORM]: + def find_by_compute_hash(self, outage_id: str, compute_hash: str) -> SLAResultORM | None: """Look up an existing SLA result by outage_id and compute_hash (#35).""" return ( self.db.query(SLAResultORM) @@ -322,7 +322,7 @@ def create_snapshot(self, snapshot_key: str = "global") -> SLAAnalyticsSnapshot: total_penalties=kpis.total_penalties, net_payout=kpis.net_payout, avg_mttr=perf.avg_mttr, - created_at=datetime.now(timezone.utc).replace(tzinfo=None), + created_at=datetime.now(UTC).replace(tzinfo=None), checksum="", # Temporary value, will be computed ) orm.checksum = orm.compute_checksum() @@ -388,7 +388,7 @@ def rebuild_snapshot(self, snapshot_key: str = "global") -> SLAAnalyticsSnapshot total_penalties=kpis.total_penalties, net_payout=kpis.net_payout, avg_mttr=perf.avg_mttr, - created_at=datetime.now(timezone.utc).replace(tzinfo=None), + created_at=datetime.now(UTC).replace(tzinfo=None), checksum="", ) orm.checksum = orm.compute_checksum() diff --git a/app/repositories/token_family_repository.py b/app/repositories/token_family_repository.py index 27e56fe..6d233ff 100644 --- a/app/repositories/token_family_repository.py +++ b/app/repositories/token_family_repository.py @@ -1,5 +1,5 @@ -from datetime import datetime, timezone -from typing import Optional +from datetime import UTC, datetime + from sqlalchemy.orm import Session from app.models.orm.token_family import TokenFamilyORM @@ -28,7 +28,7 @@ def increment_sequence(self, family_id: str) -> TokenFamilyORM | None: family = self.get_family(family_id) if family: family.current_sequence += 1 - family.updated_at = datetime.now(timezone.utc) + family.updated_at = datetime.now(UTC) self.db.commit() self.db.refresh(family) return family @@ -37,7 +37,7 @@ def compromise_family(self, family_id: str) -> TokenFamilyORM | None: family = self.get_family(family_id) if family: family.compromised = True - family.updated_at = datetime.now(timezone.utc) + family.updated_at = datetime.now(UTC) self.db.commit() self.db.refresh(family) return family diff --git a/app/repositories/user_repository.py b/app/repositories/user_repository.py index 1941cec..f042af6 100644 --- a/app/repositories/user_repository.py +++ b/app/repositories/user_repository.py @@ -1,5 +1,5 @@ -from typing import Optional -from datetime import datetime, timezone +from datetime import UTC, datetime + from sqlalchemy.orm import Session from app.models.auth import AuthUser @@ -7,7 +7,6 @@ from app.models.orm.user import UserORM - def user_orm_to_pydantic(orm: UserORM) -> AuthUser: return AuthUser( id=orm.id, @@ -65,11 +64,11 @@ def is_account_locked(self, email: str) -> bool: return False if user.locked_until is None: return False - return user.locked_until > datetime.now(timezone.utc) + return user.locked_until > datetime.now(UTC) def update_profile( - self, user_id: str, full_name: Optional[str] = None, stellar_wallet: Optional[str] = None - ) -> Optional[UserORM]: + self, user_id: str, full_name: str | None = None, stellar_wallet: str | None = None + ) -> UserORM | None: """Update mutable profile fields. Returns updated ORM or None if not found.""" user = self.get_by_id(user_id) if not user: diff --git a/app/repositories/wallet_repository.py b/app/repositories/wallet_repository.py index 381fcb2..ad8dc9f 100644 --- a/app/repositories/wallet_repository.py +++ b/app/repositories/wallet_repository.py @@ -7,8 +7,7 @@ from __future__ import annotations -from datetime import datetime, UTC -from typing import Optional +from datetime import UTC, datetime from sqlalchemy.orm import Session @@ -53,11 +52,11 @@ def create( # ── Read ────────────────────────────────────────────────────────────── - def get_by_user_id(self, user_id: str) -> Optional[WalletORM]: + def get_by_user_id(self, user_id: str) -> WalletORM | None: """Look up a wallet by user_id.""" return self.db.query(WalletORM).filter(WalletORM.user_id == user_id).first() - def get_by_public_key(self, public_key: str) -> Optional[WalletORM]: + def get_by_public_key(self, public_key: str) -> WalletORM | None: """Look up a wallet by Stellar public key.""" return self.db.query(WalletORM).filter(WalletORM.public_key == public_key).first() @@ -67,9 +66,9 @@ def update( self, wallet: WalletORM, *, - funded: Optional[bool] = None, - trustline_ready: Optional[bool] = None, - active: Optional[bool] = None, + funded: bool | None = None, + trustline_ready: bool | None = None, + active: bool | None = None, ) -> WalletORM: """Update mutable fields and refresh cached_at.""" now = datetime.now(UTC) diff --git a/app/schemas/sla_dispute.py b/app/schemas/sla_dispute.py index 03ca76b..589322f 100644 --- a/app/schemas/sla_dispute.py +++ b/app/schemas/sla_dispute.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import Optional + from pydantic import BaseModel, ConfigDict, Field from app.models.sla_dispute import DisputeStatus diff --git a/app/services/api_key_store.py b/app/services/api_key_store.py index 11a5043..3877a08 100644 --- a/app/services/api_key_store.py +++ b/app/services/api_key_store.py @@ -1,8 +1,7 @@ from __future__ import annotations import secrets -from datetime import datetime, timezone -from typing import Optional +from datetime import UTC, datetime from uuid import uuid4 from sqlalchemy.orm import Session @@ -12,7 +11,7 @@ def _now() -> datetime: - return datetime.now(timezone.utc) + return datetime.now(UTC) def _generate_id() -> str: @@ -49,7 +48,7 @@ def create_api_key( return orm, raw_key -def get_key_by_hash(db: Session, hashed_key: str) -> Optional[ApiKeyORM]: +def get_key_by_hash(db: Session, hashed_key: str) -> ApiKeyORM | None: return db.query(ApiKeyORM).filter(ApiKeyORM.hashed_key == hashed_key).first() diff --git a/app/services/audit_log.py b/app/services/audit_log.py index 2dead23..c75e57b 100644 --- a/app/services/audit_log.py +++ b/app/services/audit_log.py @@ -1,27 +1,16 @@ -issue/114-117-webhook-concurrency-canonical-json -from datetime import datetime, timezone -from typing import Any -import hashlib -from sqlalchemy.orm import Session import hashlib -import json -from datetime import datetime, timezone -from typing import Any, Optional +from datetime import UTC, datetime +from typing import Any -main from sqlalchemy import desc from sqlalchemy.orm import Session from app.core.config import settings -from app.utils.correlation_ctx import get_correlation_id -issue/114-117-webhook-concurrency-canonical-json -from app.services.formatters import canonical_json - +from app.db.session import AuditSessionLocal, SessionLocal from app.models.orm.audit_log import AuditLogORM -main +from app.services.formatters import canonical_json from app.services.scrubber import scrub_details - -from app.db.session import SessionLocal, AuditSessionLocal +from app.utils.correlation_ctx import get_correlation_id # --- SLA Settlement Audit Event Types --- SLA_SETTLEMENT_INITIATED = "sla_settlement_initiated" @@ -68,7 +57,7 @@ def log_event( if correlation_id is None: correlation_id = get_correlation_id() - created_at = datetime.now(timezone.utc) + created_at = datetime.now(UTC) last_entry = db.query(AuditLogORM).order_by(desc(AuditLogORM.id)).first() prev_hash = last_entry.entry_hash if last_entry else None entry_hash = self._compute_entry_hash(prev_hash, event_type, safe_details, correlation_id, created_at) diff --git a/app/services/auth_store.py b/app/services/auth_store.py index f78443f..c07d62f 100644 --- a/app/services/auth_store.py +++ b/app/services/auth_store.py @@ -9,13 +9,10 @@ from app.core.security import get_password_hash, hash_token, validate_password_policy, verify_password from app.db.session import SessionLocal from app.models.auth import AuthSessionResponse, AuthUser, LoginRequest, RegisterRequest -from app.repositories.user_repository import UserRepository, user_orm_to_pydantic from app.repositories.session_repository import SessionRepository from app.repositories.token_family_repository import TokenFamilyRepository from app.repositories.user_repository import UserRepository, user_orm_to_pydantic from app.services.audit_log import audit_log -from app.db.session import SessionLocal -from app.core.config import settings TOKEN_TTL_SECONDS = 3600 diff --git a/app/services/credential_stuffing_detector.py b/app/services/credential_stuffing_detector.py index 7f95a31..e992ab4 100644 --- a/app/services/credential_stuffing_detector.py +++ b/app/services/credential_stuffing_detector.py @@ -1,6 +1,5 @@ from __future__ import annotations -from redis import Redis from time import time from redis import Redis diff --git a/app/services/gdpr.py b/app/services/gdpr.py index 7f04460..a0e0082 100644 --- a/app/services/gdpr.py +++ b/app/services/gdpr.py @@ -9,20 +9,19 @@ import json import tarfile import uuid -from datetime import datetime, timezone -from typing import Any, Optional +from datetime import UTC, datetime +from typing import Any from sqlalchemy.orm import Session -from app.models.orm.user import UserORM from app.models.orm.audit_log import AuditLogORM -from app.repositories.user_repository import UserRepository +from app.models.orm.user import UserORM from app.repositories.session_repository import SessionRepository from app.repositories.token_family_repository import TokenFamilyRepository from app.services.audit_log import audit_log -def _serialize_datetime(dt: Optional[datetime]) -> Optional[str]: +def _serialize_datetime(dt: datetime | None) -> str | None: if dt is None: return None return dt.isoformat() @@ -46,9 +45,7 @@ def export_user_data(db: Session, user: UserORM) -> dict[str, Any]: # Collect audit log entries (limit to most recent 1 000 for performance) audit_entries = ( db.query(AuditLogORM) - .filter( - (AuditLogORM.email == user.email) | (AuditLogORM.actor_id == user.id) - ) + .filter((AuditLogORM.email == user.email) | (AuditLogORM.actor_id == user.id)) .order_by(AuditLogORM.created_at.desc()) .limit(1000) .all() @@ -64,7 +61,7 @@ def export_user_data(db: Session, user: UserORM) -> dict[str, Any]: ] export_payload = { - "exported_at": datetime.now(timezone.utc).isoformat(), + "exported_at": datetime.now(UTC).isoformat(), "user": user_data, "audit_logs": audit_logs, "audit_log_count": len(audit_logs), @@ -116,9 +113,9 @@ def erase_user_data(db: Session, user: UserORM) -> dict[str, Any]: # Pseudonymise personal fields user.email = f"erased-{user.id}@deleted.local" user.full_name = f"Erased User {user.id[:8]}" - user.hashed_password = "" + user.hashed_password = "" # nosec B105 - erasing credential, not a hardcoded password user.stellar_wallet = None - user.locked_until = datetime.now(timezone.utc) + user.locked_until = datetime.now(UTC) db.commit() audit_log.log_event( diff --git a/app/services/health_report.py b/app/services/health_report.py index fc578f2..9b41699 100644 --- a/app/services/health_report.py +++ b/app/services/health_report.py @@ -5,9 +5,9 @@ import time from dataclasses import dataclass +from redis import Redis from sqlalchemy import text from sqlalchemy.engine import Engine -from redis import Redis @dataclass diff --git a/app/services/job_cleanup.py b/app/services/job_cleanup.py index 2421332..95ca682 100644 --- a/app/services/job_cleanup.py +++ b/app/services/job_cleanup.py @@ -4,8 +4,8 @@ of job records and maintain database performance. """ -from datetime import datetime, timedelta, timezone -from typing import Optional +from datetime import UTC, datetime, timedelta + from sqlalchemy import delete from sqlalchemy.orm import Session @@ -43,8 +43,8 @@ def cleanup_old_jobs( successful_retention_days = successful_retention_days or self.SUCCESSFUL_JOB_RETENTION_DAYS failed_retention_days = failed_retention_days or self.FAILED_JOB_RETENTION_DAYS - cutoff_success = datetime.now(timezone.utc) - timedelta(days=successful_retention_days) - cutoff_failed = datetime.now(timezone.utc) - timedelta(days=failed_retention_days) + cutoff_success = datetime.now(UTC) - timedelta(days=successful_retention_days) + cutoff_failed = datetime.now(UTC) - timedelta(days=failed_retention_days) total_deleted = 0 stats = { @@ -139,7 +139,7 @@ def _delete_jobs_by_status( def get_retention_stats(self) -> dict: """Get current job retention statistics without deleting anything.""" - now = datetime.now(timezone.utc) + now = datetime.now(UTC) # Count jobs by status and age stats = { diff --git a/app/services/metrics.py b/app/services/metrics.py index 049d301..762acfe 100644 --- a/app/services/metrics.py +++ b/app/services/metrics.py @@ -1,11 +1,11 @@ from __future__ import annotations -import time import threading +import time from collections import defaultdict, deque -from datetime import datetime, timezone -from typing import Any, Dict, List, Optional from dataclasses import dataclass, field +from datetime import UTC, datetime +from typing import Any @dataclass @@ -24,35 +24,35 @@ class MetricsRegistry: def __init__(self) -> None: self._lock = threading.RLock() - self._counters: Dict[str, float] = defaultdict(float) - self._gauges: Dict[str, float] = {} - self._histograms: Dict[str, deque] = defaultdict(lambda: deque(maxlen=1000)) - self._histogram_buckets: Dict[str, Dict[float, int]] = defaultdict(lambda: defaultdict(int)) - self._timers: Dict[str, List[float]] = defaultdict(list) + self._counters: dict[str, float] = defaultdict(float) + self._gauges: dict[str, float] = {} + self._histograms: dict[str, deque] = defaultdict(lambda: deque(maxlen=1000)) + self._histogram_buckets: dict[str, dict[float, int]] = defaultdict(lambda: defaultdict(int)) + self._timers: dict[str, list[float]] = defaultdict(list) - def increment_counter(self, name: str, value: float = 1.0, tags: Optional[Dict[str, str]] = None) -> None: + def increment_counter(self, name: str, value: float = 1.0, tags: dict[str, str] | None = None) -> None: """Increment a counter metric.""" with self._lock: key = self._make_key(name, tags) self._counters[key] += value - def set_gauge(self, name: str, value: float, tags: Optional[Dict[str, str]] = None) -> None: + def set_gauge(self, name: str, value: float, tags: dict[str, str] | None = None) -> None: """Set a gauge metric value.""" with self._lock: key = self._make_key(name, tags) self._gauges[key] = value - def record_histogram(self, name: str, value: float, tags: Optional[Dict[str, str]] = None) -> None: + def record_histogram(self, name: str, value: float, tags: dict[str, str] | None = None) -> None: """Record a histogram value with automatic bucket tracking.""" with self._lock: key = self._make_key(name, tags) - self._histograms[key].append(MetricPoint(datetime.now(timezone.utc), value, tags or {})) + self._histograms[key].append(MetricPoint(datetime.now(UTC), value, tags or {})) # Increment histogram buckets for Prometheus-compatible export for bucket_bound in _DEFAULT_LATENCY_BUCKETS: if value <= bucket_bound: self._histogram_buckets[key][bucket_bound] += 1 - def record_timer(self, name: str, duration_ms: float, tags: Optional[Dict[str, str]] = None) -> None: + def record_timer(self, name: str, duration_ms: float, tags: dict[str, str] | None = None) -> None: """Record a timing measurement.""" with self._lock: key = self._make_key(name, tags) @@ -61,7 +61,7 @@ def record_timer(self, name: str, duration_ms: float, tags: Optional[Dict[str, s if len(self._timers[key]) > 1000: self._timers[key] = self._timers[key][-1000:] - def _make_key(self, name: str, tags: Optional[Dict[str, str]] = None) -> str: + def _make_key(self, name: str, tags: dict[str, str] | None = None) -> str: """Create a unique key for a metric with optional tags.""" if not tags: return name @@ -72,7 +72,7 @@ def get_metrics_summary(self) -> dict[str, Any]: """Get a summary of all metrics for exposure (JSON and Prometheus).""" with self._lock: summary: dict[str, Any] = { - "timestamp": datetime.now(timezone.utc).isoformat(), + "timestamp": datetime.now(UTC).isoformat(), "counters": dict(self._counters), "gauges": dict(self._gauges), "histograms": {}, @@ -110,7 +110,7 @@ def get_metrics_summary(self) -> dict[str, Any]: return summary - def _percentile(self, values: List[float], percentile: float) -> float: + def _percentile(self, values: list[float], percentile: float) -> float: """Calculate percentile of values.""" if not values: return 0.0 @@ -126,7 +126,7 @@ def _percentile(self, values: List[float], percentile: float) -> float: class TimerContext: """Context manager for timing operations.""" - def __init__(self, name: str, tags: Optional[Dict[str, str]] = None) -> None: + def __init__(self, name: str, tags: dict[str, str] | None = None) -> None: self.name = name self.tags = tags self.start_time = None @@ -141,22 +141,22 @@ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: metrics.record_timer(self.name, duration_ms, self.tags) -def timer(name: str, tags: Optional[Dict[str, str]] = None) -> TimerContext: +def timer(name: str, tags: dict[str, str] | None = None) -> TimerContext: """Create a timer context manager.""" return TimerContext(name, tags) -def increment_counter(name: str, value: float = 1.0, tags: Optional[Dict[str, str]] = None) -> None: +def increment_counter(name: str, value: float = 1.0, tags: dict[str, str] | None = None) -> None: """Increment a counter metric.""" metrics.increment_counter(name, value, tags) -def set_gauge(name: str, value: float, tags: Optional[Dict[str, str]] = None) -> None: +def set_gauge(name: str, value: float, tags: dict[str, str] | None = None) -> None: """Set a gauge metric value.""" metrics.set_gauge(name, value, tags) -def record_histogram(name: str, value: float, tags: Optional[Dict[str, str]] = None) -> None: +def record_histogram(name: str, value: float, tags: dict[str, str] | None = None) -> None: """Record a histogram value.""" metrics.record_histogram(name, value, tags) diff --git a/app/services/outage_stream_import.py b/app/services/outage_stream_import.py index 025757d..85554cb 100644 --- a/app/services/outage_stream_import.py +++ b/app/services/outage_stream_import.py @@ -6,7 +6,7 @@ from __future__ import annotations import json -from typing import Any, List +from typing import Any from sqlalchemy.orm import Session @@ -22,7 +22,7 @@ def stream_import_outages( Falls back to standard json.loads for small payloads. """ try: - rows: List[dict] = json.loads(raw_body) + rows: list[dict] = json.loads(raw_body) except (json.JSONDecodeError, UnicodeDecodeError): return {"imported": 0, "failed_rows": [], "error": "invalid json"} @@ -32,7 +32,7 @@ def stream_import_outages( if len(rows) > max_rows: rows = rows[:max_rows] - failed: List[dict] = [] + failed: list[dict] = [] imported = 0 for i in range(0, len(rows), chunk_size): diff --git a/app/services/sla/config.py b/app/services/sla/config.py index e0dcd93..c4b3a7f 100644 --- a/app/services/sla/config.py +++ b/app/services/sla/config.py @@ -4,12 +4,10 @@ import json import secrets from copy import deepcopy -from datetime import datetime, timezone -from typing import Optional +from datetime import UTC, datetime from app.models.sla import SLAConfigHistoryEntry, SLAConfigUpdateRequest, SLAPolicyContent, SLASeverityConfig - SLA_CONFIG: dict[str, dict[str, int]] = { "critical": { "threshold_minutes": 15, @@ -136,8 +134,8 @@ def update_config_for_severity(severity: str, payload: SLAConfigUpdateRequest) - def publish_config_for_severity( severity: str, payload: SLAConfigUpdateRequest, - expected_token: Optional[str] = None, - published_by: Optional[str] = None, + expected_token: str | None = None, + published_by: str | None = None, ) -> tuple[SLAPolicyContent, str, SLAConfigHistoryEntry]: """Atomically publish a new policy version with optimistic concurrency (#37). @@ -179,7 +177,7 @@ def publish_config_for_severity( content_hash = _compute_content_hash(normalized, new_config, new_version) # Build history entry - now = datetime.now(timezone.utc) + now = datetime.now(UTC) history = SLAConfigHistoryEntry( severity=normalized, policy_version=new_version, @@ -205,5 +203,3 @@ def publish_config_for_severity( class ConcurrencyError(Exception): """Raised when an optimistic concurrency check fails (→ 409).""" - - pass diff --git a/app/services/sla/sla_calculator.py b/app/services/sla/sla_calculator.py index 265f8e6..a41adb1 100644 --- a/app/services/sla/sla_calculator.py +++ b/app/services/sla/sla_calculator.py @@ -2,6 +2,7 @@ import time from app.models import SLAResult + from ..metrics import record_histogram from .config import SLA_CONFIG, get_config_for_severity diff --git a/app/services/sla_cache.py b/app/services/sla_cache.py index 1e6c874..900f6fa 100644 --- a/app/services/sla_cache.py +++ b/app/services/sla_cache.py @@ -1,7 +1,7 @@ """Redis caching layer for SLA calculation results (#25).""" import json -from typing import Callable, Optional +from collections.abc import Callable from redis import Redis @@ -16,7 +16,7 @@ def __init__(self, redis: Redis, ttl: int = 60): def _key(self, device_id: str, period: str) -> str: return f"sla:{device_id}:{period}" - def get(self, device_id: str, period: str) -> Optional[dict]: + def get(self, device_id: str, period: str) -> dict | None: raw = self._redis.get(self._key(device_id, period)) if raw: return json.loads(raw) diff --git a/app/services/sla_service.py b/app/services/sla_service.py index b2f8ad4..1e21ca8 100644 --- a/app/services/sla_service.py +++ b/app/services/sla_service.py @@ -1,14 +1,16 @@ from __future__ import annotations import logging -from datetime import datetime, timezone -from typing import Any, Dict, List, Optional, Union +import re +from datetime import UTC, datetime +from typing import Any + from sqlalchemy.orm import Session from app.core.exceptions import ApexTransientError from app.models.orm.outage import OutageORM from app.models.orm.sla import SLAResultORM -from app.models.sla import SLACalculationResult, SLACalculationError +from app.models.sla import SLACalculationResult from app.services.audit_log import audit_log from app.services.metrics import increment_counter @@ -63,7 +65,7 @@ def parse_period(self, period: str) -> tuple[datetime, datetime]: detail=f"Unsupported period format: '{period}'. Expected 'YYYY-MM' or 'YYYY-QN'.", ) - def get_outages_for_device(self, device_id: str, start_date: datetime, end_date: datetime) -> List[OutageORM]: + def get_outages_for_device(self, device_id: str, start_date: datetime, end_date: datetime) -> list[OutageORM]: """Get all outages for a device within the specified period.""" return ( self.db.query(OutageORM) @@ -73,7 +75,7 @@ def get_outages_for_device(self, device_id: str, start_date: datetime, end_date: .all() ) - def calculate_mttr(self, outages: List[OutageORM]) -> float: + def calculate_mttr(self, outages: list[OutageORM]) -> float: """Calculate Mean Time To Resolution for outages.""" if not outages: return 0.0 @@ -86,13 +88,13 @@ def calculate_mttr(self, outages: List[OutageORM]) -> float: mttr_values.append(mttr_minutes) elif outage.started_at: # For unresolved outages, calculate time since start - duration = datetime.now(timezone.utc) - outage.started_at + duration = datetime.now(UTC) - outage.started_at mttr_minutes = duration.total_seconds() / 60 mttr_values.append(mttr_minutes) return round(sum(mttr_values) / len(mttr_values), 2) if mttr_values else 0.0 - def calculate_availability(self, outages: List[OutageORM], period_days: int) -> float: + def calculate_availability(self, outages: list[OutageORM], period_days: int) -> float: """Calculate availability percentage for the period.""" if not outages: return 100.0 @@ -106,13 +108,13 @@ def calculate_availability(self, outages: List[OutageORM], period_days: int) -> downtime_minutes += downtime.total_seconds() / 60 elif outage.started_at: # For unresolved outages, calculate downtime since start - downtime = datetime.now(timezone.utc) - outage.started_at + downtime = datetime.now(UTC) - outage.started_at downtime_minutes += downtime.total_seconds() / 60 availability = max(0.0, (total_minutes - downtime_minutes) / total_minutes * 100) return round(availability, 2) - def check_sla_violations(self, availability: float, mttr: float, sla_thresholds: Dict[str, float]) -> bool: + def check_sla_violations(self, availability: float, mttr: float, sla_thresholds: dict[str, float]) -> bool: """Check if SLA thresholds are violated.""" availability_threshold = sla_thresholds.get("availability", 99.9) mttr_threshold = sla_thresholds.get("mttr", 60.0) # minutes @@ -121,7 +123,7 @@ def check_sla_violations(self, availability: float, mttr: float, sla_thresholds: def compute_device_sla( - db: Session, device_id: str, period: str, sla_thresholds: Optional[Dict[str, float]] = None + db: Session, device_id: str, period: str, sla_thresholds: dict[str, float] | None = None ) -> SLACalculationResult: """ Compute SLA metrics for a device with real domain orchestration. @@ -138,7 +140,7 @@ def compute_device_sla( """ orchestrator = SLAOrchestrator(db) increment_counter("sla_recomputation_total", tags={"device_id": device_id, "period": period}) - + # Default SLA thresholds if not provided if sla_thresholds is None: sla_thresholds = { @@ -242,7 +244,7 @@ def compute_device_sla( def record_sla_settlement_audit_events( device_id: str, period: str, - sla_result: Union[SLACalculationResult, dict[str, Any]], + sla_result: SLACalculationResult | dict[str, Any], *, status: str = "initiated", error: str | None = None, diff --git a/app/services/token_revocation.py b/app/services/token_revocation.py index caac2bd..e1d1ae6 100644 --- a/app/services/token_revocation.py +++ b/app/services/token_revocation.py @@ -3,7 +3,6 @@ Stores revoked token hashes in Redis with TTL matching the token's remaining lifetime. """ -from typing import Optional from redis import Redis from app.core.config import settings diff --git a/app/services/wallet_cache.py b/app/services/wallet_cache.py index a591354..946c8da 100644 --- a/app/services/wallet_cache.py +++ b/app/services/wallet_cache.py @@ -1,7 +1,5 @@ """Redis-backed read-through cache for wallet reads (#29).""" -from typing import Optional - class WalletCache: """Read-through cache wrapping Redis for wallet lookups.""" @@ -13,7 +11,7 @@ def __init__(self, redis_client, ttl: int = 60): def _key(self, address: str) -> str: return f"wallet:{address}" - def get(self, address: str) -> Optional[dict]: + def get(self, address: str) -> dict | None: import json raw = self._redis.get(self._key(address)) diff --git a/app/services/wallet_registry.py b/app/services/wallet_registry.py index e95812b..d833ee4 100644 --- a/app/services/wallet_registry.py +++ b/app/services/wallet_registry.py @@ -8,14 +8,13 @@ from __future__ import annotations -from datetime import datetime, UTC -from typing import Optional +from datetime import UTC, datetime from uuid import uuid4 from sqlalchemy.orm import Session -from app.core.config import settings from app.core.exceptions import ApexConflictError +from app.models.orm.wallet import WalletORM from app.models.wallet import ( AssetBalance, Wallet, @@ -27,7 +26,6 @@ WalletStatusResponse, WalletTrustlineResponse, ) -from app.models.orm.wallet import WalletORM from app.repositories.wallet_repository import WalletRepository from app.services.wallet_cache import WalletCache @@ -76,7 +74,7 @@ def create_wallet( cls, db: Session, payload: WalletCreateRequest, - cache: Optional[WalletCache] = None, + cache: WalletCache | None = None, ) -> WalletCreateResponse: repo = WalletRepository(db) @@ -109,7 +107,7 @@ def link_wallet( cls, db: Session, payload: WalletLinkRequest, - cache: Optional[WalletCache] = None, + cache: WalletCache | None = None, ) -> Wallet: """Link a wallet to a user with comprehensive conflict detection (BE-032). @@ -173,7 +171,7 @@ def _fetch_wallet( cls, db: Session, user_id: str, - cache: Optional[WalletCache] = None, + cache: WalletCache | None = None, ) -> Wallet | None: """Internal helper: fetch from cache or DB, update cache on miss.""" repo = WalletRepository(db) @@ -193,7 +191,7 @@ def _fetch_by_address( cls, db: Session, address: str, - cache: Optional[WalletCache] = None, + cache: WalletCache | None = None, ) -> Wallet | None: """Internal helper: fetch by public key from cache or DB.""" if cache: @@ -220,7 +218,7 @@ def get_wallet( cls, db: Session, user_id: str, - cache: Optional[WalletCache] = None, + cache: WalletCache | None = None, ) -> Wallet | None: return cls._fetch_wallet(db, user_id, cache=cache) @@ -229,7 +227,7 @@ def get_balance( cls, db: Session, address: str, - cache: Optional[WalletCache] = None, + cache: WalletCache | None = None, ) -> WalletBalanceResponse | None: wallet = cls._fetch_by_address(db, address, cache=cache) if not wallet: @@ -260,7 +258,7 @@ def get_status( cls, db: Session, user_id: str, - cache: Optional[WalletCache] = None, + cache: WalletCache | None = None, ) -> WalletStatusResponse | None: wallet = cls.get_wallet(db, user_id, cache=cache) if not wallet: @@ -283,7 +281,7 @@ def get_trustline( cls, db: Session, user_id: str, - cache: Optional[WalletCache] = None, + cache: WalletCache | None = None, ) -> WalletTrustlineResponse | None: wallet = cls.get_wallet(db, user_id, cache=cache) if not wallet: @@ -303,7 +301,7 @@ def get_funding_state( cls, db: Session, user_id: str, - cache: Optional[WalletCache] = None, + cache: WalletCache | None = None, ) -> WalletFundingStateResponse | None: wallet = cls.get_wallet(db, user_id, cache=cache) if not wallet: diff --git a/app/services/webhook_breaker.py b/app/services/webhook_breaker.py index 40c047c..3b09ee2 100644 --- a/app/services/webhook_breaker.py +++ b/app/services/webhook_breaker.py @@ -32,6 +32,7 @@ def __init__( def _host_key(self, url: str) -> str: from urllib.parse import urlparse + try: return urlparse(str(url)).hostname or str(url) except Exception: diff --git a/app/services/webhook_service.py b/app/services/webhook_service.py index deeab23..8ff9885 100644 --- a/app/services/webhook_service.py +++ b/app/services/webhook_service.py @@ -2,28 +2,25 @@ import logging import os import random +from collections.abc import Iterator from contextlib import contextmanager -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from threading import Lock, Semaphore -from typing import Any, Iterator +from typing import Any from uuid import UUID import httpx from sqlalchemy.orm import Session from app.core.config import settings +from app.core.tracing import get_current_traceparent, traced from app.models.webhook import Webhook, WebhookDelivery, WebhookDeliveryStatus, WebhookEvent from app.services.formatters import canonical_json +from app.services.webhook_breaker import breaker from app.services.webhook_signing import ( CURRENT_SIGNATURE_VERSION, sign_payload, ) -from app.services.webhook_breaker import breaker -from app.core.config import settings -issue/114-117-webhook-concurrency-canonical-json - -from app.core.tracing import get_current_traceparent, traced -main from app.utils.correlation_ctx import get_or_generate_correlation_id from app.utils.network_validation import validate_webhook_url @@ -76,9 +73,9 @@ def _apply_jitter(delay: float) -> float: if mode == "none": return delay if mode == "equal": - return delay * random.uniform(0.5, 1.5) + return delay * random.uniform(0.5, 1.5) # nosec B311 - retry jitter, not security # "full" (default): random in [0, nominal*2], floor 1s - return max(1.0, random.uniform(0, delay * 2)) + return max(1.0, random.uniform(0, delay * 2)) # nosec B311 - retry jitter, not security WEBHOOK_SCHEMA_VERSION = "1" @@ -112,7 +109,7 @@ def _build_headers( headers = { "Content-Type": "application/json", "X-Webhook-Event": event.value, - "X-Webhook-Timestamp": datetime.now(timezone.utc).isoformat(), + "X-Webhook-Timestamp": datetime.now(UTC).isoformat(), } # Inject OTel trace context (traceparent) for distributed tracing @@ -236,17 +233,18 @@ def dispatch_delivery(db: Session, delivery_id: UUID) -> None: delivery.status = WebhookDeliveryStatus.BREAKER_OPEN delivery.error_message = "Circuit breaker open, delivery deferred" delivery.next_retry_at = None - delivery.updated_at = datetime.now(timezone.utc) + delivery.updated_at = datetime.now(UTC) db.commit() logger.warning( "Webhook delivery %s deferred for webhook %s: circuit breaker open.", - delivery.id, webhook.id, + delivery.id, + webhook.id, ) return delivery.attempt_count += 1 delivery.status = WebhookDeliveryStatus.RETRYING if delivery.attempt_count > 1 else WebhookDeliveryStatus.PENDING - delivery.updated_at = datetime.now(timezone.utc) + delivery.updated_at = datetime.now(UTC) db.commit() with dispatch_limiter.acquire(str(webhook.id)): @@ -254,7 +252,7 @@ def dispatch_delivery(db: Session, delivery_id: UUID) -> None: if success: delivery.status = WebhookDeliveryStatus.SUCCESS - delivery.delivered_at = datetime.now(timezone.utc) + delivery.delivered_at = datetime.now(UTC) delivery.next_retry_at = None logger.info( "Webhook delivery %s succeeded on attempt %d for webhook %s.", @@ -275,7 +273,7 @@ def dispatch_delivery(db: Session, delivery_id: UUID) -> None: delay = _apply_jitter(raw_delay) # Enforce the hard cap after jitter to prevent jitter from exceeding the ceiling delay = min(delay, settings.WEBHOOK_RETRY_MAX_DELAY_SECONDS) - delivery.next_retry_at = datetime.now(timezone.utc) + timedelta(seconds=delay) + delivery.next_retry_at = datetime.now(UTC) + timedelta(seconds=delay) delivery.status = WebhookDeliveryStatus.RETRYING logger.warning( "Webhook delivery %s failed (attempt %d). Retrying in %ds.", @@ -285,7 +283,7 @@ def dispatch_delivery(db: Session, delivery_id: UUID) -> None: ) else: delivery.status = WebhookDeliveryStatus.DEAD_LETTER - delivery.dead_lettered_at = datetime.now(timezone.utc) + delivery.dead_lettered_at = datetime.now(UTC) delivery.next_retry_at = None logger.error( "Webhook delivery %s permanently failed after %d attempts. Marked as dead-letter.", @@ -293,7 +291,7 @@ def dispatch_delivery(db: Session, delivery_id: UUID) -> None: delivery.attempt_count, ) - delivery.updated_at = datetime.now(timezone.utc) + delivery.updated_at = datetime.now(UTC) db.commit() @@ -324,7 +322,7 @@ def trigger_sla_violation_webhooks( deliveries = [] # Timestamp is captured once and reused across all retries (idempotency support) - event_timestamp = datetime.now(timezone.utc).isoformat() + event_timestamp = datetime.now(UTC).isoformat() payload = { "schema_version": WEBHOOK_SCHEMA_VERSION, @@ -356,7 +354,7 @@ def trigger_sla_violation_webhooks( def retry_pending_deliveries(db: Session) -> int: - now = datetime.now(timezone.utc) + now = datetime.now(UTC) due_deliveries = ( db.query(WebhookDelivery) .filter( @@ -374,9 +372,7 @@ def retry_pending_deliveries(db: Session) -> int: return count -def get_dead_letter_deliveries( - db: Session, webhook_id: UUID | None = None, limit: int = 100 -) -> list[WebhookDelivery]: +def get_dead_letter_deliveries(db: Session, webhook_id: UUID | None = None, limit: int = 100) -> list[WebhookDelivery]: """Get dead-lettered deliveries for auditing and remediation.""" query = ( db.query(WebhookDelivery) @@ -410,7 +406,7 @@ def replay_dead_letter_delivery(db: Session, delivery_id: UUID) -> bool: delivery.response_status_code = None delivery.response_body = None delivery.delivered_at = None - delivery.updated_at = datetime.now(timezone.utc) + delivery.updated_at = datetime.now(UTC) db.commit() @@ -441,9 +437,7 @@ def replay_deliveries_by_event_context( payload = json.loads(delivery.payload) data = payload.get("data", {}) - if device_id and data.get("device_id") == device_id: - matching_deliveries.append(delivery) - elif outage_id and data.get("outage_id") == outage_id: + if device_id and data.get("device_id") == device_id or outage_id and data.get("outage_id") == outage_id: matching_deliveries.append(delivery) except (json.JSONDecodeError, TypeError): continue diff --git a/app/services/webhook_signing.py b/app/services/webhook_signing.py index 0e6c745..d8c49a2 100644 --- a/app/services/webhook_signing.py +++ b/app/services/webhook_signing.py @@ -36,9 +36,8 @@ import hashlib import hmac -from datetime import datetime, timezone -from typing import Any, List, Optional, Tuple - +from datetime import UTC, datetime +from typing import Any # Current signature algorithm version CURRENT_SIGNATURE_VERSION = 1 @@ -121,7 +120,7 @@ def verify_signature_with_grace( payload: str, signature: str, version: int = CURRENT_SIGNATURE_VERSION, - previous_secrets: Optional[List[dict[str, Any]]] = None, + previous_secrets: list[dict[str, Any]] | None = None, ) -> bool: """Verify signature against current secret and valid previous secrets. @@ -144,7 +143,7 @@ def verify_signature_with_grace( if not previous_secrets: return False - now = datetime.now(timezone.utc) + now = datetime.now(UTC) for entry in previous_secrets: expires_at = datetime.fromisoformat(entry["expires_at"]) if expires_at < now: diff --git a/app/tasks/sla_tasks.py b/app/tasks/sla_tasks.py index 3bb80d4..a321c8a 100644 --- a/app/tasks/sla_tasks.py +++ b/app/tasks/sla_tasks.py @@ -1,17 +1,16 @@ import json import logging -from datetime import datetime, timezone -from typing import Any, Dict, List, Optional +from datetime import UTC, datetime +from typing import Any from celery import Task -from app.tasks.celery_app import celery_app - +from app.core.exceptions import ApexTransientError from app.db.session import SessionLocal from app.models.job import Job, JobStatus, JobType from app.models.webhook import WebhookEvent from app.services.audit_log import audit_log -from app.core.exceptions import ApexTransientError +from app.tasks.celery_app import celery_app from app.utils.correlation_ctx import set_correlation_id from app.utils.logging import get_structured_logger @@ -35,7 +34,7 @@ def _mark_started(self, db, celery_task_id: str): job = self._get_job(db, celery_task_id) if job: job.status = JobStatus.STARTED - job.started_at = datetime.now(timezone.utc) + job.started_at = datetime.now(UTC) db.commit() def _mark_success(self, db, celery_task_id: str, result: Any): @@ -44,7 +43,7 @@ def _mark_success(self, db, celery_task_id: str, result: Any): job.status = JobStatus.SUCCESS job.result = json.dumps(result) job.progress = 100.0 - job.finished_at = datetime.now(timezone.utc) + job.finished_at = datetime.now(UTC) db.commit() def _mark_failure(self, db, celery_task_id: str, error: str): @@ -52,7 +51,7 @@ def _mark_failure(self, db, celery_task_id: str, error: str): if job: job.status = JobStatus.FAILURE job.error = error - job.finished_at = datetime.now(timezone.utc) + job.finished_at = datetime.now(UTC) db.commit() def _update_progress(self, db, celery_task_id: str, progress: float, details: dict[str, Any] | None = None): @@ -107,8 +106,8 @@ def _log_retry(self, db, celery_task_id: str, retry_count: int, error: str): default_retry_delay=30, ) def compute_sla_for_device( - self: DatabaseTask, device_id: str, period: str, correlation_id: Optional[str] = None -) -> Dict[str, Any]: + self: DatabaseTask, device_id: str, period: str, correlation_id: str | None = None +) -> dict[str, Any]: """ Compute SLA metrics for a single device over a given period. Triggers SLA violation webhooks if thresholds are breached. diff --git a/app/tasks/webhook_secret_housekeeping.py b/app/tasks/webhook_secret_housekeeping.py index f470c52..c3249b9 100644 --- a/app/tasks/webhook_secret_housekeeping.py +++ b/app/tasks/webhook_secret_housekeeping.py @@ -1,6 +1,7 @@ """Scheduled task to expire old webhook secrets after the grace period.""" -from datetime import datetime, timezone +from datetime import UTC, datetime + from app.db.session import SessionLocal from app.models.webhook import Webhook diff --git a/app/tasks/webhook_tasks.py b/app/tasks/webhook_tasks.py index f147ee7..198b267 100644 --- a/app/tasks/webhook_tasks.py +++ b/app/tasks/webhook_tasks.py @@ -1,10 +1,11 @@ import logging -from typing import Any, Dict +from typing import Any from uuid import UUID +from app.core.exceptions import ApexTransientError from app.db.session import SessionLocal from app.services.audit_log import audit_log -from app.core.exceptions import ApexTransientError +from app.tasks.celery_app import celery_app logger = logging.getLogger(__name__) @@ -68,7 +69,7 @@ def retry_pending_webhook_deliveries() -> dict[str, Any]: max_retries=3, default_retry_delay=15, ) -def trigger_sla_violation_async(self, sla_data: Dict[str, Any], event: str = "sla.violation") -> Dict[str, Any]: +def trigger_sla_violation_async(self, sla_data: dict[str, Any], event: str = "sla.violation") -> dict[str, Any]: """ Async task wrapper around webhook_service.trigger_sla_violation_webhooks. Called from SLA computation tasks to avoid blocking. diff --git a/app/utils/correlation_ctx.py b/app/utils/correlation_ctx.py index d8b7f03..66a3e49 100644 --- a/app/utils/correlation_ctx.py +++ b/app/utils/correlation_ctx.py @@ -1,13 +1,11 @@ import uuid from contextvars import ContextVar -from typing import Optional - # Context variable to store correlation ID across the request lifecycle -correlation_id_var: ContextVar[Optional[str]] = ContextVar("correlation_id", default=None) +correlation_id_var: ContextVar[str | None] = ContextVar("correlation_id", default=None) -def get_correlation_id() -> Optional[str]: +def get_correlation_id() -> str | None: """Get the current correlation ID from context.""" return correlation_id_var.get() diff --git a/app/utils/logging.py b/app/utils/logging.py index c465ec2..6f78e2c 100644 --- a/app/utils/logging.py +++ b/app/utils/logging.py @@ -1,6 +1,6 @@ import json import logging -from datetime import datetime, timezone +from datetime import UTC, datetime from app.utils.correlation_ctx import get_correlation_id @@ -14,7 +14,7 @@ def __init__(self, name: str): def _format_message(self, level: str, message: str, **kwargs) -> str: """Format a log message with structured context.""" log_entry = { - "timestamp": datetime.now(timezone.utc).isoformat(), + "timestamp": datetime.now(UTC).isoformat(), "level": level, "message": message, "logger": self.logger.name, diff --git a/src/apexchainx_backend.egg-info/PKG-INFO b/src/apexchainx_backend.egg-info/PKG-INFO new file mode 100644 index 0000000..c55765c --- /dev/null +++ b/src/apexchainx_backend.egg-info/PKG-INFO @@ -0,0 +1,438 @@ +Metadata-Version: 2.4 +Name: apexchainx-backend +Version: 1.0.0 +Summary: ApexChainx Backend API for SLA-aware telecom operations +Author: ApexChainx Team +License: MIT +Requires-Python: >=3.11 +Description-Content-Type: text/markdown +Requires-Dist: fastapi==0.115.6 +Requires-Dist: uvicorn[standard]==0.34.0 +Requires-Dist: redis==5.2.1 +Requires-Dist: sqlalchemy==2.0.36 +Requires-Dist: psycopg2-binary==2.9.10 +Requires-Dist: alembic==1.14.0 +Requires-Dist: pydantic-settings==2.7.0 +Requires-Dist: celery==5.4.0 +Requires-Dist: httpx==0.28.1 +Requires-Dist: python-multipart==0.0.19 +Requires-Dist: passlib[bcrypt]==1.7.4 +Requires-Dist: gunicorn==23.0.0 +Provides-Extra: dev +Requires-Dist: pytest==8.3.4; extra == "dev" +Requires-Dist: pytest-cov==6.0.0; extra == "dev" +Requires-Dist: ruff==0.8.3; extra == "dev" +Requires-Dist: mypy==1.13.0; extra == "dev" +Requires-Dist: bandit==1.8.0; extra == "dev" +Requires-Dist: pip-tools==7.4.1; extra == "dev" + +# ApexChainx Backend + +![Python](https://img.shields.io/badge/python-3.11+-blue) ![FastAPI](https://img.shields.io/badge/FastAPI-0.100+-green) ![PostgreSQL](https://img.shields.io/badge/postgres-15+-blue) ![Stellar](https://img.shields.io/badge/Stellar-Soroban-blueviolet) ![License](https://img.shields.io/badge/license-MIT-lightgrey) + +> FastAPI backend for automated SLA management, outage tracking, Stellar blockchain payments, and audit infrastructure. + +## Architecture + +ApexChainx is a 3-repo monorepo split across frontend, backend, and smart contracts: + +| Repo | Role | +|------|------| +| `apexchainx-fe` | Frontend UI | +| `apexchainx-be` | **Backend and integration layer** (this repo) | +| `apexchainx-contracts` | Soroban smart contracts | + +**System flow:** `User → FE → BE → Contracts → BE → FE` + +The frontend never calls contracts directly. The backend is the sole bridge between the UI and on-chain execution. All Soroban interactions are brokered exclusively through `apexchainx-be`. + +## Overview + +ApexChainx is a 3-repo + +`apexchainx-be` is a FastAPI application that serves as the central processing layer for the ApexChainx platform. + +It is responsible for: + + +- **Outage management** — create, update, resolve, and search outages with full lifecycle tracking +- **SLA computation** — calculate MTTR-based SLA outcomes with penalty and reward logic +- **Audit logging** — immutable audit trail with correlation IDs across all operations +- **Stellar payments** — bridge to Soroban smart contracts for SLA-triggered settlements +- **Webhook delivery** — signed, versioned event delivery with retry and idempotency support +- **Analytics** — SLA performance aggregation, trends, snapshots, and CSV/JSON exports + +The `outages` and `sla` domains are the strongest and most integration-focused. The audit domain records all state-changing operations immutably. Other domains (`auth`, `payments`, `wallets`, `jobs`, `webhooks`) are fully routed but vary in infrastructure depth. + +## Tech Stack + +All runtime dependencies are pinned in [requirements.txt](requirements.txt). No floating version ranges are used. + +| Component | Technology | +|-----------|------------| +| Language | Python 3.11+ | +| Framework | FastAPI | +| ORM | SQLAlchemy | +| Database | PostgreSQL | +| Migrations | Alembic | +| Settings | Pydantic Settings | +| Task Queue | Celery | +| HTTP Client | HTTPX | +| Blockchain | Stellar / Soroban | + +Dependencies are declared in [requirements.txt](requirements.txt). + +## Active Routes + +The app entrypoint is [app/main.py](app/main.py). Routes are wired through [app/api/v1/router.py](app/api/v1/router.py). + +Current active routes: + +- `/health` — liveness and readiness probes +- `/api/v1/audit` — immutable event audit log +- `/api/v1/jobs` +- `/api/v1/outages` — full outage lifecycle management +- `/api/v1/sla` — SLA computation, analytics, and exports +- `/api/v1/sla/disputes` — file, list, and resolve SLA disputes +- `/api/v1/auth` +- `/api/v1/payments` +- `/api/v1/webhooks` +- `/api/v1/wallets` + +Module maturity: + +- **Strongest integration**: `outages`, `sla`, `audit` +- **Active with lighter implementations**: `auth`, `payments`, `wallets` +- **Infrastructure-dependent**: `jobs`, `webhooks`, `sla/disputes` (require Redis and Celery for full behavior) + +Dormant or contributor-only paths: + +- `app/services/outage_store.py` — legacy helper, not part of the routed runtime +- `CONTRACT_EXECUTION_MODE` — controls whether the local SLA adapter or the Soroban contract bridge is active + +## Outage and SLA Flow + +The core backend lifecycle for outage resolution and SLA settlement: + +1. Create or update an outage +2. Resolve the outage with `mttr_minutes` +3. Compute the SLA outcome (penalty or reward) via MTTR-based policy evaluation +4. Persist the resulting SLA record and emit an audit event +5. Optionally trigger a Stellar payment via the contract adapter +6. Return the resolved outage and SLA result to the frontend + +Key implementation files: + +| File | Role | +|------|------| +| `app/api/v1/endpoints/outages.py` | Outage route handlers | +| `app/api/v1/endpoints/sla.py` | SLA route handlers | +| `app/repositories/outage_repository.py` | Outage DB access | +| `app/repositories/sla_repository.py` | SLA DB access | +| `app/services/sla/sla_calculator.py` | MTTR-based SLA computation | +| `app/services/sla/config.py` | SLA policy thresholds and penalty/reward configuration | +| `app/services/audit_log.py` | Audit event emission service | + +The backend includes both a local SLA execution path and a Soroban contract adapter. The local adapter is the default. Contract-backed execution is enabled via `CONTRACT_EXECUTION_MODE` in the environment. + +## Project Structure + +```text +apexchainx-be/ +├── alembic/ # database migration config and versions +├── app/ +│ ├── api/v1/endpoints/ # FastAPI route handlers +│ ├── core/ # settings and application config +│ ├── db/ # SQLAlchemy base and session setup +│ ├── middleware/ # correlation ID and payload size middleware +│ ├── models/ # Pydantic and ORM models +│ ├── repositories/ # DB access layer +│ ├── services/ # domain logic and utilities +│ ├── tasks/ # Celery task modules +│ └── utils/ # helpers such as exporters and analytics +├── docs/ # project and integration documentation +├── tests/ # test suite +├── requirements.txt +└── README.md +``` + +## Local Setup + +### Prerequisites + +Ensure all prerequisites are installed before proceeding. + +- Python 3.11+ recommended +- PostgreSQL +- pip +- A virtual environment tool (venv or equivalent) + +### Clone the Repository + +```bash +git clone https://github.com/ApexChainx/ApexChainx-Backend.git +cd ApexChainx-Backend +``` + +### Install Dependencies + +```bash +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +``` + +### Configure Environment + +Copy the example environment file and fill in your values: + +```bash +cp .env.example .env +# Edit .env with your actual configuration values +``` + +**SECURITY WARNING**: Never commit `.env` to version control. The `.env.example` file contains placeholder values only. + +See in the repo root for the full list of supported variables and their default values. + +### Environment Validation + +Startup fails fast on misconfiguration. The following rules are enforced: + +- `API_V1_PREFIX` must start with `/` +- `DATABASE_URL` must include a URL scheme +- `ALLOWED_ORIGINS` must be valid `http` or `https` origins +- `STELLAR_NETWORK` and `CONTRACT_EXECUTION_MODE` must be recognised values +- When `CELERY_TASK_ALWAYS_EAGER=false`, both `CELERY_BROKER_URL` and `CELERY_RESULT_BACKEND` must be present + +### Run Migrations + +```bash +alembic upgrade head +``` + +A migration verification helper is available at `tests/test_verify_migrations.py` to validate the Alembic chain and confirm the database matches the head revision. + +### Start the API + +```bash +uvicorn app.main:app --reload +``` + +The backend will be available at: + +| Endpoint | URL | +|----------|-----| +| Base | `http://localhost:8000` | +| Swagger UI | `http://localhost:8000/docs` | +| Liveness | `http://localhost:8000/health/liveness` | +| Readiness | `http://localhost:8000/health/readiness` | +| Legacy health | `http://localhost:8000/health` | + +## Verification Notes + +![Verified](https://img.shields.io/badge/status-stabilized-brightgreen) + +As of the latest stabilization pass: + +- Python modules compile cleanly +- `app.main` imports successfully +- `/health` returns `200` + +To exercise outage and SLA routes meaningfully, a reachable PostgreSQL instance is required because those routes depend on the database layer. + +## Current Limitations + +This backend is stabilized but not feature-complete. Known limitations: + +- `auth` and `wallets` are active but currently backed by lightweight in-memory stores rather than durable identity infrastructure +- `jobs` and `webhooks` are routed, but rely on optional worker infrastructure (Redis, Celery) to be fully operational outside eager or local modes +- the contract path exists, but the default runtime favors the local adapter mode +- documentation and contributor expectations should follow the routed API surface, not every helper or legacy module under `app/services` +- SLA dispute resolution requires Redis and Celery for async notification delivery + +## Security Guidelines + +### For Contributors + +See [docs/STELLAR_INTEGRATION.md](docs/STELLAR_INTEGRATION.md) for Stellar-specific security requirements including key management and testnet/mainnet separation. + + +**Never commit sensitive information:** + +- API keys, secret keys, or passwords +- Private keys for any blockchain network +- Database connection strings with credentials +- JWT secrets or encryption keys +- Personal access tokens + +**Always use environment variables for:** + +- Database credentials +- API keys and secrets +- Blockchain private keys +- JWT signing secrets +- External service credentials + +**Documentation examples must:** + +- Use placeholder values clearly marked as examples +- Never include real credentials or keys +- Include security warnings where sensitive operations are discussed +- Show secure patterns (environment variables, secure key management) + +### Environment Variables Reference + +```env +# Database +DATABASE_URL=postgresql://user:password@localhost:5432/apexchainx +``` + +```env +# Authentication +JWT_SECRET_KEY=your-jwt-secret-here +``` + +```env +# Stellar Blockchain +STELLAR_NETWORK=testnet +STELLAR_POOL_SECRET_KEY=your-stellar-secret-key-here +CONTRACT_EXECUTION_MODE=local +``` + +```env +# Task Queue (required when CELERY_TASK_ALWAYS_EAGER=false) +CELERY_BROKER_URL=redis://localhost:6379/0 +CELERY_RESULT_BACKEND=redis://localhost:6379/1 +CELERY_TASK_ALWAYS_EAGER=true +``` + +```env +# API configuration +API_V1_PREFIX=/api/v1 +ALLOWED_ORIGINS=http://localhost:3000,https://app.apexchainx.com +``` + +### Reporting Security Issues + +If you discover a security vulnerability: + +1. Do not create a public issue +2. Email security@apexchainx.com with details +3. Allow time for the issue to be addressed before public disclosure + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) for branch conventions, commit style, and PR process. + +All contributions must target a feature branch. Direct pushes to are not accepted. + +## Additional Documentation + +- [docs/API.md](docs/API.md) — full API reference with request/response examples +- [docs/STELLAR_INTEGRATION.md](docs/STELLAR_INTEGRATION.md) — Soroban contract integration and payment flow +- [docs/WEBHOOK_INTEGRATION.md](docs/WEBHOOK_INTEGRATION.md) — webhook signing, delivery, and retry +- [docs/CODEX_CONTEXT.md](docs/CODEX_CONTEXT.md) — contributor context and session inventory +- [docs/PROJECT_CONTEXT.md](docs/PROJECT_CONTEXT.md) — high-level project positioning and goals + +## Tests + +The test suite lives in `tests/`. Key test files: + +| File | Coverage | +|------|----------| +| `tests/test_outage_lifecycle.py` | Outage create/resolve/search lifecycle | +| `tests/test_sla_analytics.py` | SLA analytics aggregation | +| `tests/test_contract_parity.py` | Local vs contract adapter parity | +| `tests/test_webhook_signature_versioning.py` | Webhook signing and versioning | +| `tests/test_config_validation.py` | Startup configuration validation | +| `tests/test_verify_migrations.py` | Alembic migration chain integrity | +| `tests/test_analytics_export.py` | CSV and JSON export correctness | +| `tests/test_payload_guardrails.py` | Payload size middleware enforcement | + +Run tests: + +```bash +pytest tests/ +``` + +Run a single test file: + +```bash +pytest tests/test_outage_lifecycle.py -v +``` + +## Middleware + +| Middleware | File | Behaviour | +|-----------|------|-----------| +| Correlation ID | `app/middleware/correlation.py` | Injects `X-Correlation-ID` header on every request and response | +| Payload size guard | `app/middleware/payload_size.py` | Enforces a cumulative payload size limit; rejects oversized requests | + +## Analytics and Exports + +SLA analytics are served through `/api/v1/sla` and support: + +- aggregated performance summaries +- trend windows over configurable time ranges +- point-in-time snapshots +- CSV and JSON export via `app/utils/analytics_exporter.py` + +Exports are available at and . + +## Database Migrations + +Migrations are managed with Alembic. The chain lives in `alembic/versions/` and covers: + +- initial tables (outages, SLA results, payments) +- operational tables and audit correlation +- SLA analytics snapshots and latest-flag +- token families and auth rate limiting +- wallet persistence and payment deduplication +- webhook secret metadata and signature versioning +- job retry tracking and SLA latest backfill + +Run `alembic history --verbose` to inspect the full chain with revision details. Run `alembic current` to see the active revision on your database. Use `tests/test_verify_migrations.py` to confirm your database is at head. + +Key revisions: + +| Revision | Description | +|----------|-------------| +| | Initial tables: outages, SLA results, payments | +| | Operational tables and audit events | +| | SLA analytics snapshots | +| | Token families | +| | Wallet persistence | +| | Payment deduplication | +| | Audit correlation IDs | +| | Webhook signature versioning | + +## Background Tasks + +SLA dispute resolution notifications are delivered asynchronously via Celery when `CELERY_TASK_ALWAYS_EAGER=false`. + + +Background task modules live in `app/tasks/`: + +| Module | Purpose | +|--------|---------| +| `celery_app.py` | Celery application factory | +| `sla_tasks.py` | Async SLA computation and settlement tasks | +| `webhook_tasks.py` | Async webhook delivery with retry logic | + +Tasks run eagerly (in-process) when `CELERY_TASK_ALWAYS_EAGER=true`. For production use set `CELERY_TASK_ALWAYS_EAGER=false` and provide Redis URLs. + +## Payments and Wallets + +All payment amounts are denominated in USDC on the Stellar network. + + +The `payments` and `wallets` domains handle SLA-triggered financial settlements: + +- **Payments** — record and query Stellar payment transactions linked to SLA outcomes +- **Wallets** — register and resolve Stellar wallet addresses per entity +- Both are fully routed but backed by in-memory stores in the current release; persistence is planned for a future iteration. + +## Changelog + +See [git log](https://github.com/ApexChainx/ApexChainx-Backend/commits/main) for the full commit history. diff --git a/src/apexchainx_backend.egg-info/SOURCES.txt b/src/apexchainx_backend.egg-info/SOURCES.txt new file mode 100644 index 0000000..de4d80d --- /dev/null +++ b/src/apexchainx_backend.egg-info/SOURCES.txt @@ -0,0 +1,17 @@ +README.md +pyproject.toml +src/apexchainx_backend.egg-info/PKG-INFO +src/apexchainx_backend.egg-info/SOURCES.txt +src/apexchainx_backend.egg-info/dependency_links.txt +src/apexchainx_backend.egg-info/requires.txt +src/apexchainx_backend.egg-info/top_level.txt +tests/test_be_205_228_236_238.py +tests/test_check_stellar_networks.py +tests/test_config_validation.py +tests/test_cors_and_security.py +tests/test_health_endpoints.py +tests/test_migration_verification.py +tests/test_outage_rules.py +tests/test_query_plans.py +tests/test_rate_limiter_redis.py +tests/test_webhook_ssrf.py \ No newline at end of file diff --git a/src/apexchainx_backend.egg-info/dependency_links.txt b/src/apexchainx_backend.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/apexchainx_backend.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/src/apexchainx_backend.egg-info/requires.txt b/src/apexchainx_backend.egg-info/requires.txt new file mode 100644 index 0000000..4013abc --- /dev/null +++ b/src/apexchainx_backend.egg-info/requires.txt @@ -0,0 +1,20 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +redis==5.2.1 +sqlalchemy==2.0.36 +psycopg2-binary==2.9.10 +alembic==1.14.0 +pydantic-settings==2.7.0 +celery==5.4.0 +httpx==0.28.1 +python-multipart==0.0.19 +passlib[bcrypt]==1.7.4 +gunicorn==23.0.0 + +[dev] +pytest==8.3.4 +pytest-cov==6.0.0 +ruff==0.8.3 +mypy==1.13.0 +bandit==1.8.0 +pip-tools==7.4.1 diff --git a/src/apexchainx_backend.egg-info/top_level.txt b/src/apexchainx_backend.egg-info/top_level.txt new file mode 100644 index 0000000..7ee14a4 --- /dev/null +++ b/src/apexchainx_backend.egg-info/top_level.txt @@ -0,0 +1,2 @@ +sla-config-history +sla-trace diff --git a/tests/benchmarks/test_benchmarks.py b/tests/benchmarks/test_benchmarks.py index 224b9b2..9972c5d 100644 --- a/tests/benchmarks/test_benchmarks.py +++ b/tests/benchmarks/test_benchmarks.py @@ -5,17 +5,15 @@ Acceptance: <30s nightly, >1.2x median fails (regression alert). """ -from datetime import datetime, timezone - import pytest from app.services.metrics import MetricsRegistry -from app.services.sla.sla_calculator import SLACalculator from app.services.sla.config import SLA_CONFIG - +from app.services.sla.sla_calculator import SLACalculator # ── Fixtures ──────────────────────────────────────────────────────────────── + @pytest.fixture def metrics_registry() -> MetricsRegistry: """Fresh metrics registry with pre-populated data for benchmarking.""" @@ -36,6 +34,7 @@ def sla_calculator() -> SLACalculator: # ── Benchmark 1: SLA calculator (hottest path) ────────────────────────────── + def test_benchmark_sla_calculator_violated(benchmark, sla_calculator): """Benchmark SLA calculation for a violated SLA (critical severity).""" result = benchmark( @@ -77,6 +76,7 @@ def test_benchmark_sla_calculator_exceptional(benchmark, sla_calculator): # ── Benchmark 2: Metrics registry (hot path for observability) ────────────── + def test_benchmark_increment_counter(benchmark, metrics_registry): """Benchmark counter increment (frequent hot-path operation).""" benchmark(metrics_registry.increment_counter, "requests_total", tags={"method": "GET"}) @@ -108,6 +108,7 @@ def test_benchmark_metrics_summary(benchmark, metrics_registry): # ── Benchmark 3: SLA config hash computation ───────────────────────────────── + def test_benchmark_sla_config_hash(benchmark): """Benchmark SLA config content hash computation (integrity verification).""" from app.services.sla.config import _compute_content_hash @@ -123,11 +124,13 @@ def test_benchmark_sla_config_hash(benchmark): # ── Benchmark 4: Webhook header building ───────────────────────────────────── + def test_benchmark_webhook_header_building(benchmark): """Benchmark webhook header generation (includes SHA-256 signing).""" - from app.services.webhook_service import _build_headers from unittest.mock import MagicMock + from app.services.webhook_service import _build_headers + webhook = MagicMock() webhook.secret = "test-secret-key-for-benchmarking" webhook.id = "wh-bench-001" @@ -141,11 +144,13 @@ def test_benchmark_webhook_header_building(benchmark): # ── Benchmark 5: Period parsing ────────────────────────────────────────────── + def test_benchmark_period_parsing_monthly(benchmark): """Benchmark period parsing for monthly SLA computation.""" - from app.services.sla_service import SLAOrchestrator from unittest.mock import MagicMock + from app.services.sla_service import SLAOrchestrator + orchestrator = SLAOrchestrator(MagicMock()) start, end = benchmark(orchestrator.parse_period, "2024-06") assert start.month == 6 @@ -154,9 +159,10 @@ def test_benchmark_period_parsing_monthly(benchmark): def test_benchmark_period_parsing_quarterly(benchmark): """Benchmark period parsing for quarterly SLA computation.""" - from app.services.sla_service import SLAOrchestrator from unittest.mock import MagicMock + from app.services.sla_service import SLAOrchestrator + orchestrator = SLAOrchestrator(MagicMock()) start, end = benchmark(orchestrator.parse_period, "2024-Q3") assert start.month == 7 diff --git a/tests/chaos/test_db_latency.py b/tests/chaos/test_db_latency.py index de05ae7..d55dfc8 100644 --- a/tests/chaos/test_db_latency.py +++ b/tests/chaos/test_db_latency.py @@ -53,6 +53,7 @@ def test_db_query_with_normal_latency(self): self._add_latency_toxic(50, 10) try: from app.db.session import engine + start = time.time() with engine.connect() as conn: conn.execute(text("SELECT 1")) @@ -68,11 +69,13 @@ def test_db_query_with_high_latency(self): from tenacity import retry, stop_after_attempt, wait_fixed from app.db.session import engine + @retry(stop=stop_after_attempt(3), wait=wait_fixed(1)) def query(): with engine.connect() as conn: conn.execute(text("SELECT 1")) conn.commit() + start = time.time() query() elapsed = (time.time() - start) * 1000 @@ -86,6 +89,7 @@ def test_db_query_with_timeout(self): from sqlalchemy import exc as sa_exc from app.db.session import engine + start = time.time() timeout = 5 try: @@ -108,6 +112,7 @@ def test_health_endpoint_survives_db_latency(self): from fastapi.testclient import TestClient from app.main import app + client = TestClient(app) resp = client.get("/health/liveness") assert resp.status_code == 200 diff --git a/tests/chaos/test_redis_down.py b/tests/chaos/test_redis_down.py index 4cc797c..b5b3f4d 100644 --- a/tests/chaos/test_redis_down.py +++ b/tests/chaos/test_redis_down.py @@ -59,6 +59,7 @@ def test_rate_limiter_handles_redis_down(self): self._add_timeout_toxic() try: import fakeredis + test_redis = fakeredis.FakeRedis() test_redis.set("rate_limit:test", "1") val = test_redis.get("rate_limit:test") @@ -70,6 +71,7 @@ def test_cache_fallback_on_redis_latency(self): self._add_latency_toxic(5000) try: import fakeredis + fake_redis = fakeredis.FakeRedis() fake_redis.set("test_key", "cached_value") val = fake_redis.get("test_key") @@ -83,6 +85,7 @@ def test_health_readiness_handles_redis_down(self): from fastapi.testclient import TestClient from app.main import app + client = TestClient(app) resp = client.get("/health/readiness") assert resp.status_code in (200, 503) @@ -95,6 +98,7 @@ def test_app_does_not_hang_on_redis_timeout(self): from fastapi.testclient import TestClient from app.main import app + client = TestClient(app) start = time.time() resp = client.get("/health/liveness") diff --git a/tests/factories.py b/tests/factories.py index 4b85d1e..b5f35d7 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -1,10 +1,10 @@ import itertools -from datetime import datetime +from datetime import datetime, timezone from uuid import uuid4 from app.api.v1.endpoints.webhooks import WebhookCreate from app.models.auth import LoginRequest, RegisterRequest -from app.models.enums import Role, Severity, OutageStatus +from app.models.enums import OutageStatus, Role, Severity from app.models.outage import Location, Outage from app.models.outage_dto import OutageCreate from app.models.payment import PaymentTransaction diff --git a/tests/properties/sla_scenarios.py b/tests/properties/sla_scenarios.py index c66021d..cd0fb15 100644 --- a/tests/properties/sla_scenarios.py +++ b/tests/properties/sla_scenarios.py @@ -9,12 +9,11 @@ from __future__ import annotations -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from hypothesis import strategies as st from hypothesis.strategies import SearchStrategy - # ── Severity and policy version generators ────────────────────────────── SEVERITIES = ["critical", "high", "medium", "low"] @@ -86,10 +85,10 @@ def edge_datetime_pair(draw: st.DrawFn) -> tuple[datetime, datetime]: # type: i - Year boundary """ edge_dates = [ - datetime(2024, 2, 29, 10, 0, 0, tzinfo=timezone.utc), # leap day - datetime(2024, 12, 31, 23, 59, 0, tzinfo=timezone.utc), # year boundary - datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc), # year start - datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), # normal mid-year + datetime(2024, 2, 29, 10, 0, 0, tzinfo=UTC), # leap day + datetime(2024, 12, 31, 23, 59, 0, tzinfo=UTC), # year boundary + datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC), # year start + datetime(2024, 6, 15, 12, 0, 0, tzinfo=UTC), # normal mid-year ] started = draw(st.sampled_from(edge_dates)) diff --git a/tests/test_api_version_middleware.py b/tests/test_api_version_middleware.py index dcf0eb3..b8f562b 100644 --- a/tests/test_api_version_middleware.py +++ b/tests/test_api_version_middleware.py @@ -12,9 +12,6 @@ def test_x_api_version_header_present(self): resp = client.get("/health/liveness") assert resp.headers.get("X-API-Version") == settings.VERSION - - # m - def test_x_api_commit_header_default(self): with TestClient(app) as client: resp = client.get("/health/liveness") @@ -36,13 +33,6 @@ def test_headers_on_all_endpoints(self): assert resp.headers.get("X-API-Version") is not None assert resp.headers.get("X-API-Commit") is not None - def test_headers_on_error_responses(self): - with TestClient(app) as client: - resp = client.get("/nonexistent") - assert resp.status_code == 404 - assert resp.headers.get("X-API-Version") is not None - assert resp.headers.get("X-API-Commit") is not None - def test_headers_on_error_responses(self): with TestClient(app) as client: resp = client.get("/nonexistent") diff --git a/tests/test_be_205_228_236_238.py b/tests/test_be_205_228_236_238.py index f0c0cd3..a41fc74 100644 --- a/tests/test_be_205_228_236_238.py +++ b/tests/test_be_205_228_236_238.py @@ -233,7 +233,8 @@ def fake_attempt(d, w): patch("app.services.webhook_service.settings") as mock_settings, patch("app.services.webhook_service.datetime") as mock_dt, ): - from datetime import datetime as real_dt, timedelta + from datetime import datetime as real_dt + from datetime import timedelta mock_settings.WEBHOOK_RETRY_BASE_DELAYS = "9999,9999,9999" mock_settings.WEBHOOK_RETRY_MAX_DELAY_SECONDS = 120 diff --git a/tests/test_contract_parity.py b/tests/test_contract_parity.py index d65e833..bdb4172 100644 --- a/tests/test_contract_parity.py +++ b/tests/test_contract_parity.py @@ -21,9 +21,9 @@ from app.services.sla import SLACalculator from tests.properties.sla_scenarios import ( SEVERITIES, - sla_scenario, scenario_with_policy_bump, scenario_with_repeated_mttr, + sla_scenario, ) # ── Config ────────────────────────────────────────────────────────────── diff --git a/tests/test_correlation_exceptions.py b/tests/test_correlation_exceptions.py index 3d23ac3..8d728ef 100644 --- a/tests/test_correlation_exceptions.py +++ b/tests/test_correlation_exceptions.py @@ -1,8 +1,7 @@ from fastapi.testclient import TestClient -from app.main import app from app.core.exceptions import ApexException, ApexNotFoundError, ApexTransientError - +from app.main import app client = TestClient(app, raise_server_exceptions=False) diff --git a/tests/test_db_error_handling.py b/tests/test_db_error_handling.py index 823c3b3..b3f8911 100644 --- a/tests/test_db_error_handling.py +++ b/tests/test_db_error_handling.py @@ -1,9 +1,10 @@ -from fastapi.testclient import TestClient -from sqlalchemy.exc import IntegrityError +import asyncio +import json from unittest.mock import Mock -from app.main import app -from app.core.exceptions import _extract_integrity_fields +from sqlalchemy.exc import IntegrityError + +from app.core.exceptions import _extract_integrity_fields, integrity_error_handler class _MockOrig: @@ -11,10 +12,15 @@ def __init__(self, msg: str): self.args = (msg,) +def _mock_request() -> Mock: + request = Mock() + request.url.path = "/api/v1/outages/" + return request + + def test_extract_integrity_fields_parses_key(): msg = ( - 'duplicate key value violates unique constraint "uq_outage_id"\n' - "DETAIL: Key (id)=(OUT-001) already exists." + 'duplicate key value violates unique constraint "uq_outage_id"\n' "DETAIL: Key (id)=(OUT-001) already exists." ) exc = IntegrityError("stmt", {}, _MockOrig(msg)) assert _extract_integrity_fields(exc) == {"id": "OUT-001"} @@ -26,23 +32,15 @@ def test_extract_integrity_fields_no_match(): def test_integrity_handler_returns_409(): - client = TestClient(app) msg = ( - 'duplicate key value violates unique constraint "uq_outage_id"\n' - "DETAIL: Key (id)=(OUT-001) already exists." + 'duplicate key value violates unique constraint "uq_outage_id"\n' "DETAIL: Key (id)=(OUT-001) already exists." ) exc = IntegrityError("stmt", {}, _MockOrig(msg)) - response = client.post( - "/api/v1/outages/", - json={"site_name": "test", "severity": "high", "description": "Test outage"}, - ) - - if response.status_code == 422: - return + response = asyncio.run(integrity_error_handler(_mock_request(), exc)) assert response.status_code == 409 - body = response.json() + body = json.loads(response.body) assert body["title"] == "Conflict" assert body["status"] == 409 assert "fields" in body diff --git a/tests/test_gdpr.py b/tests/test_gdpr.py index 3c63890..176d51e 100644 --- a/tests/test_gdpr.py +++ b/tests/test_gdpr.py @@ -7,8 +7,8 @@ import pytest from fastapi.testclient import TestClient -from app.main import app from app.db.session import SessionLocal +from app.main import app from app.models.auth import RegisterRequest from app.services.auth_store import AuthStore diff --git a/tests/test_impersonation.py b/tests/test_impersonation.py index cb678a2..bb40d4c 100644 --- a/tests/test_impersonation.py +++ b/tests/test_impersonation.py @@ -3,9 +3,9 @@ import pytest from fastapi.testclient import TestClient -from app.main import app from app.db.session import SessionLocal -from app.models.auth import RegisterRequest, LoginRequest +from app.main import app +from app.models.auth import LoginRequest, RegisterRequest from app.models.orm.user import UserORM from app.services.auth_store import AuthStore diff --git a/tests/test_openapi_contract.py b/tests/test_openapi_contract.py index 9b0e4a1..ed3c820 100644 --- a/tests/test_openapi_contract.py +++ b/tests/test_openapi_contract.py @@ -3,6 +3,6 @@ def test_schemathesis_import(): """Verify schemathesis is importable and ready for contract tests.""" - import schemathesis # noqa: F401 + import schemathesis assert schemathesis.__version__ is not None diff --git a/tests/test_period_parsing.py b/tests/test_period_parsing.py index 5963bd7..43e5b5c 100644 --- a/tests/test_period_parsing.py +++ b/tests/test_period_parsing.py @@ -4,11 +4,12 @@ formats, and raises typed validation errors on bad input. """ -import pytest from datetime import datetime -from app.services.sla_service import SLAOrchestrator +import pytest + from app.core.exceptions import ApexValidationError +from app.services.sla_service import SLAOrchestrator def _make_orchestrator(): diff --git a/tests/test_pydantic_error_envelope.py b/tests/test_pydantic_error_envelope.py index 0abbf34..cb3154a 100644 --- a/tests/test_pydantic_error_envelope.py +++ b/tests/test_pydantic_error_envelope.py @@ -1,8 +1,7 @@ from fastapi.testclient import TestClient -from pydantic import ValidationError -from app.main import app from app.core.exceptions import _build_rfc7807 +from app.main import app def test_rfc7807_structure(): @@ -30,7 +29,7 @@ def test_validation_handler_on_bad_request(): assert response.status_code == 422 body = response.json() - assert body["title"] == "Validation Error" + assert body["title"] == "Unprocessable Entity" assert body["status"] == 422 assert "errors" in body assert "type" in body diff --git a/tests/test_sla_typing.py b/tests/test_sla_typing.py index cb7bf3b..a2d8d9f 100644 --- a/tests/test_sla_typing.py +++ b/tests/test_sla_typing.py @@ -4,11 +4,9 @@ than loose dicts, and that OpenAPI can reflect the new shape. """ -from datetime import datetime - import pytest -from app.models.sla import SLACalculationResult, SLACalculationError +from app.models.sla import SLACalculationError, SLACalculationResult class TestSLACalculationResult: diff --git a/tests/test_tx_memo.py b/tests/test_tx_memo.py index 40985db..faf855e 100644 --- a/tests/test_tx_memo.py +++ b/tests/test_tx_memo.py @@ -4,7 +4,6 @@ from app.services.tx_memo import MEMO_MAX_BYTES, TxMemo - # ── Round-trip golden payloads ────────────────────────────────────────── GOLDEN_PAYLOADS = [ diff --git a/tests/test_wallet_persistence.py b/tests/test_wallet_persistence.py index 84441d2..30d4bd7 100644 --- a/tests/test_wallet_persistence.py +++ b/tests/test_wallet_persistence.py @@ -8,11 +8,10 @@ import pytest from sqlalchemy import create_engine -from sqlalchemy.orm import sessionmaker, Session +from sqlalchemy.orm import Session, sessionmaker from app.db.base import Base from app.repositories.wallet_repository import WalletRepository -from app.models.orm.wallet import WalletORM @pytest.fixture(scope="function")