Реализована интеграция с СМС провайдером
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""HAN SMS service."""
|
||||
@@ -0,0 +1,253 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from enum import StrEnum
|
||||
|
||||
import asyncpg
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
DateTime,
|
||||
Enum,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
Numeric,
|
||||
SmallInteger,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncEngine,
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
SCHEMA = "sms"
|
||||
|
||||
|
||||
class Channel(StrEnum):
|
||||
SMS = "SMS"
|
||||
|
||||
|
||||
class SendStatus(StrEnum):
|
||||
PENDING = "pending"
|
||||
ACCEPTED = "accepted"
|
||||
REJECTED = "rejected"
|
||||
FAILED = "failed"
|
||||
UNCERTAIN = "uncertain"
|
||||
SKIPPED = "skipped"
|
||||
|
||||
|
||||
class DeliveryStatus(StrEnum):
|
||||
UNKNOWN = "unknown"
|
||||
SENT = "sent"
|
||||
DELIVERED = "delivered"
|
||||
UNDELIVERED = "undelivered"
|
||||
UNSENT = "unsent"
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class SmsTemplate(Base):
|
||||
__tablename__ = "sms_template"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("code", "channel", "locale", "version", name="uq_template_version"),
|
||||
Index(
|
||||
"uq_template_active",
|
||||
"code",
|
||||
"channel",
|
||||
"locale",
|
||||
unique=True,
|
||||
postgresql_where=text("is_active"),
|
||||
),
|
||||
{"schema": SCHEMA},
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True)
|
||||
code: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
channel: Mapped[Channel] = mapped_column(
|
||||
Enum(
|
||||
Channel,
|
||||
name="sms_channel",
|
||||
schema=SCHEMA,
|
||||
values_callable=lambda x: [e.value for e in x],
|
||||
)
|
||||
)
|
||||
locale: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
version: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
body_template: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
placeholders: Mapped[list[str]] = mapped_column(JSONB, nullable=False)
|
||||
sender_name: Mapped[str | None] = mapped_column(String(64))
|
||||
max_parts: Mapped[int] = mapped_column(SmallInteger, nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
approved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
created_by: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
|
||||
|
||||
class SmsSetting(Base):
|
||||
__tablename__ = "sms_setting"
|
||||
__table_args__ = {"schema": SCHEMA}
|
||||
|
||||
setting_key: Mapped[str] = mapped_column(String(128), primary_key=True)
|
||||
setting_value: Mapped[object] = mapped_column(JSONB, nullable=False)
|
||||
value_type: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
description: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
|
||||
class SmsOutboundMessage(Base):
|
||||
__tablename__ = "sms_outbound_message"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("requester_service", "idempotency_key", name="uq_outbound_idempotency"),
|
||||
Index(
|
||||
"uq_outbound_provider_message",
|
||||
"provider",
|
||||
"provider_message_id",
|
||||
unique=True,
|
||||
postgresql_where=text("provider_message_id IS NOT NULL"),
|
||||
),
|
||||
Index("ix_outbound_phone_created", "phone_e164", text("created_at DESC")),
|
||||
Index(
|
||||
"ix_outbound_requester_process_created",
|
||||
"requester_service",
|
||||
"process",
|
||||
text("created_at DESC"),
|
||||
),
|
||||
Index("ix_outbound_customer_ref", "customer_ref"),
|
||||
Index("ix_outbound_send_created", "send_status", "created_at"),
|
||||
Index("ix_outbound_delivery_updated", "delivery_status", "updated_at"),
|
||||
{"schema": SCHEMA},
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
requested_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
accepted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
delivered_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
requester_service: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
process: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
channel: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
provider: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
phone_e164: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
phone_digits: Mapped[str] = mapped_column(String(15), nullable=False)
|
||||
phone_masked: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
template_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey(f"{SCHEMA}.sms_template.id"), nullable=False
|
||||
)
|
||||
template_code: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
body_rendered: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
substitutions: Mapped[dict[str, object]] = mapped_column(JSONB, nullable=False)
|
||||
send_status: Mapped[SendStatus] = mapped_column(
|
||||
Enum(
|
||||
SendStatus,
|
||||
name="sms_send_status",
|
||||
schema=SCHEMA,
|
||||
values_callable=lambda x: [e.value for e in x],
|
||||
),
|
||||
nullable=False,
|
||||
)
|
||||
delivery_status: Mapped[DeliveryStatus] = mapped_column(
|
||||
Enum(
|
||||
DeliveryStatus,
|
||||
name="sms_delivery_status",
|
||||
schema=SCHEMA,
|
||||
values_callable=lambda x: [e.value for e in x],
|
||||
),
|
||||
nullable=False,
|
||||
)
|
||||
provider_message_id: Mapped[str | None] = mapped_column(String(128))
|
||||
provider_external_id: Mapped[str | None] = mapped_column(String(128))
|
||||
customer_ref: Mapped[str | None] = mapped_column(String(128))
|
||||
idempotency_key: Mapped[str] = mapped_column(String(192), nullable=False)
|
||||
request_fingerprint: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
request_id: Mapped[str | None] = mapped_column(String(128))
|
||||
traceparent: Mapped[str | None] = mapped_column(String(55))
|
||||
provider_http_status: Mapped[int | None] = mapped_column(Integer)
|
||||
provider_error_code: Mapped[str | None] = mapped_column(String(64))
|
||||
provider_error_message: Mapped[str | None] = mapped_column(String(256))
|
||||
sender_name: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
message_ttl_sec: Mapped[int | None] = mapped_column(Integer)
|
||||
attempt_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
last_attempt_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
next_attempt_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
worker_locked_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
parts: Mapped[int | None] = mapped_column(Integer)
|
||||
price: Mapped[Decimal | None] = mapped_column(Numeric(14, 4))
|
||||
currency: Mapped[str | None] = mapped_column(String(3))
|
||||
callback_last_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
|
||||
class SmsCallbackEvent(Base):
|
||||
__tablename__ = "sms_callback_event"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"message_uuid",
|
||||
"callback_event",
|
||||
"status",
|
||||
"status_time",
|
||||
name="uq_callback_event",
|
||||
),
|
||||
{"schema": SCHEMA},
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True)
|
||||
message_uuid: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
callback_event: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
status_time: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
received_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
|
||||
def asyncpg_dsn(url: str) -> str:
|
||||
return url.replace("postgresql+asyncpg://", "postgresql://", 1)
|
||||
|
||||
|
||||
def create_postgres_engine(url: str) -> AsyncEngine:
|
||||
dsn = asyncpg_dsn(url)
|
||||
|
||||
async def connect() -> asyncpg.Connection:
|
||||
return await asyncpg.connect(dsn=dsn)
|
||||
|
||||
return create_async_engine(
|
||||
"postgresql+asyncpg://", async_creator=connect, pool_pre_ping=True
|
||||
)
|
||||
|
||||
|
||||
class Database:
|
||||
def __init__(self, url: str) -> None:
|
||||
self.engine = create_postgres_engine(url)
|
||||
self.sessions = async_sessionmaker(self.engine, expire_on_commit=False)
|
||||
|
||||
async def session(self) -> AsyncIterator[AsyncSession]:
|
||||
async with self.sessions() as session:
|
||||
yield session
|
||||
|
||||
async def close(self) -> None:
|
||||
await self.engine.dispose()
|
||||
@@ -0,0 +1,154 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import re
|
||||
import string
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import phonenumbers
|
||||
|
||||
from app.db import DeliveryStatus, SendStatus
|
||||
|
||||
GSM_BASIC = (
|
||||
"@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞ "
|
||||
"!\"#¤%&'()*+,-./0123456789:;<=>?"
|
||||
"¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà"
|
||||
)
|
||||
GSM_EXTENDED = "^{}\\[~]|€"
|
||||
PHONE_RE = re.compile(r"^\+[1-9]\d{7,14}$")
|
||||
|
||||
|
||||
class DomainError(Exception):
|
||||
def __init__(
|
||||
self, code: str, status: int, message: str, details: dict[str, Any] | None = None
|
||||
) -> None:
|
||||
self.code = code
|
||||
self.status = status
|
||||
self.message = message
|
||||
self.details = details or {}
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
def normalize_phone(value: str) -> tuple[str, str, str]:
|
||||
if not PHONE_RE.fullmatch(value):
|
||||
raise DomainError("sms_request_invalid", 422, "phone_e164 must be valid E.164")
|
||||
try:
|
||||
parsed = phonenumbers.parse(value, None)
|
||||
except phonenumbers.NumberParseException:
|
||||
raise DomainError("sms_request_invalid", 422, "phone_e164 must be valid E.164") from None
|
||||
if not phonenumbers.is_valid_number(parsed):
|
||||
raise DomainError("sms_request_invalid", 422, "phone_e164 must be valid E.164")
|
||||
normalized = phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
|
||||
if normalized != value:
|
||||
raise DomainError("sms_request_invalid", 422, "phone_e164 must be canonical E.164")
|
||||
digits = normalized[1:]
|
||||
masked = f"+{digits[:1]}{'*' * max(0, len(digits) - 5)}{digits[-4:]}"
|
||||
return normalized, digits, masked
|
||||
|
||||
|
||||
def request_fingerprint(payload: dict[str, Any]) -> str:
|
||||
canonical = json.dumps(
|
||||
payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False
|
||||
)
|
||||
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def destination_hmac(phone_e164: str, key: bytes) -> str:
|
||||
return hmac.new(key, phone_e164.encode(), hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def sms_parts(body: str) -> int:
|
||||
if not body or "\ufeff" in body or "\x00" in body:
|
||||
raise DomainError("sms_request_invalid", 422, "Rendered message contains invalid text")
|
||||
gsm_units = 0
|
||||
for char in body:
|
||||
if char in GSM_BASIC:
|
||||
gsm_units += 1
|
||||
elif char in GSM_EXTENDED:
|
||||
gsm_units += 2
|
||||
else:
|
||||
total = len(body.encode("utf-16-be")) // 2
|
||||
return 1 if total <= 70 else (total + 66) // 67
|
||||
return 1 if gsm_units <= 160 else (gsm_units + 152) // 153
|
||||
|
||||
|
||||
def render_template(
|
||||
body_template: str,
|
||||
placeholders: list[str],
|
||||
substitutions: dict[str, Any],
|
||||
max_parts: int,
|
||||
) -> str:
|
||||
expected = set(placeholders)
|
||||
supplied = set(substitutions)
|
||||
if expected != supplied:
|
||||
raise DomainError(
|
||||
"sms_request_invalid",
|
||||
422,
|
||||
"Substitutions do not match template placeholders",
|
||||
{"missing": sorted(expected - supplied), "unknown": sorted(supplied - expected)},
|
||||
)
|
||||
parsed = {
|
||||
field_name
|
||||
for _, field_name, format_spec, conversion in string.Formatter().parse(body_template)
|
||||
if field_name is not None
|
||||
and not format_spec
|
||||
and not conversion
|
||||
and field_name.isidentifier()
|
||||
}
|
||||
if parsed != expected or any(
|
||||
format_spec or conversion
|
||||
for _, field_name, format_spec, conversion in string.Formatter().parse(body_template)
|
||||
if field_name is not None
|
||||
):
|
||||
raise DomainError("sms_request_invalid", 422, "Template placeholder contract is invalid")
|
||||
body = body_template.format_map({key: str(value) for key, value in substitutions.items()})
|
||||
if len(body.encode("utf-8")) > 2048 or sms_parts(body) > max_parts:
|
||||
raise DomainError("sms_request_invalid", 422, "Rendered message exceeds template limit")
|
||||
return body
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderResult:
|
||||
send_status: SendStatus
|
||||
http_status: int | None = None
|
||||
message_uuid: str | None = None
|
||||
external_id: str | None = None
|
||||
error_code: str | None = None
|
||||
error_message: str | None = None
|
||||
retry_safe: bool = False
|
||||
contract_violation: bool = False
|
||||
|
||||
|
||||
DELIVERY_RANK = {
|
||||
DeliveryStatus.UNKNOWN: 0,
|
||||
DeliveryStatus.SENT: 1,
|
||||
DeliveryStatus.DELIVERED: 2,
|
||||
DeliveryStatus.UNDELIVERED: 2,
|
||||
DeliveryStatus.UNSENT: 2,
|
||||
}
|
||||
|
||||
|
||||
def delivery_transition(current: DeliveryStatus, incoming: str) -> DeliveryStatus | None:
|
||||
try:
|
||||
target = DeliveryStatus(incoming.lower())
|
||||
except ValueError:
|
||||
return None
|
||||
if DELIVERY_RANK[target] < DELIVERY_RANK[current]:
|
||||
return current
|
||||
if DELIVERY_RANK[target] == DELIVERY_RANK[current] and target != current:
|
||||
return current
|
||||
return target
|
||||
|
||||
|
||||
def parse_status_time(value: str) -> datetime:
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
raise DomainError("callback_invalid", 422, "Invalid callback status_time") from None
|
||||
if parsed.tzinfo is None:
|
||||
raise DomainError("callback_invalid", 422, "Callback status_time requires timezone")
|
||||
return parsed
|
||||
@@ -0,0 +1,322 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import hmac
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Annotated, Any
|
||||
|
||||
import structlog
|
||||
import uvicorn
|
||||
from fastapi import Body, Depends, FastAPI, Header, Request, Response
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
from prometheus_client import CONTENT_TYPE_LATEST, generate_latest
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy import func, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
|
||||
from app.db import Database, SmsTemplate
|
||||
from app.domain import DomainError
|
||||
from app.metrics import CALLBACK_LAG, CALLBACK_TOTAL
|
||||
from app.schemas import CallbackItem, ErrorEnvelope, MessageResponse, SendRequest, SendResponse
|
||||
from app.service import (
|
||||
apply_callback,
|
||||
create_order,
|
||||
load_runtime_settings,
|
||||
read_message,
|
||||
)
|
||||
from app.settings import get_settings
|
||||
|
||||
log = structlog.get_logger()
|
||||
|
||||
|
||||
def configure_logging(level: str) -> None:
|
||||
logging.basicConfig(level=level, format="%(message)s")
|
||||
structlog.configure(
|
||||
processors=[
|
||||
structlog.contextvars.merge_contextvars,
|
||||
structlog.processors.TimeStamper(fmt="iso", utc=True, key="timestamp"),
|
||||
structlog.stdlib.add_log_level,
|
||||
structlog.processors.JSONRenderer(),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
settings = get_settings()
|
||||
configure_logging(settings.log_level)
|
||||
app.state.settings = settings
|
||||
app.state.db = Database(settings.database_url)
|
||||
yield
|
||||
await app.state.db.close()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="HAN SMS Service",
|
||||
version="1.0.0",
|
||||
openapi_version="3.1.0",
|
||||
docs_url=None,
|
||||
redoc_url=None,
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def request_context(request: Request, call_next: Any) -> Response:
|
||||
supplied = request.headers.get("X-Request-ID", "").strip()
|
||||
request_id = supplied[:128] if supplied and supplied.isprintable() else str(uuid.uuid4())
|
||||
request.state.request_id = request_id
|
||||
started = time.monotonic()
|
||||
structlog.contextvars.clear_contextvars()
|
||||
structlog.contextvars.bind_contextvars(
|
||||
request_id=request_id,
|
||||
method=request.method,
|
||||
route=request.url.path,
|
||||
**{"service.name": "sms-service"},
|
||||
)
|
||||
response = await call_next(request)
|
||||
response.headers["X-Request-ID"] = request_id
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
log.info(
|
||||
"request.complete",
|
||||
status_code=response.status_code,
|
||||
duration_ms=round((time.monotonic() - started) * 1000, 2),
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
def error_response(
|
||||
request: Request,
|
||||
code: str,
|
||||
message: str,
|
||||
status: int,
|
||||
details: dict[str, Any] | list[dict[str, Any]] | None = None,
|
||||
) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=status,
|
||||
content={
|
||||
"error": {
|
||||
"code": code,
|
||||
"message": message,
|
||||
"request_id": getattr(request.state, "request_id", str(uuid.uuid4())),
|
||||
"details": details or {},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(DomainError)
|
||||
async def domain_error(request: Request, exc: DomainError) -> JSONResponse:
|
||||
response = error_response(request, exc.code, exc.message, exc.status, exc.details)
|
||||
if "retry_after" in exc.details:
|
||||
response.headers["Retry-After"] = str(exc.details["retry_after"])
|
||||
return response
|
||||
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_error(request: Request, exc: RequestValidationError) -> JSONResponse:
|
||||
details = [
|
||||
{"field": ".".join(str(part) for part in item["loc"][1:]), "type": item["type"]}
|
||||
for item in exc.errors()
|
||||
]
|
||||
log.warning("request.validation_failed", details=details)
|
||||
return error_response(
|
||||
request, "sms_request_invalid", "SMS request validation failed", 422, details
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(StarletteHTTPException)
|
||||
async def http_error(request: Request, exc: StarletteHTTPException) -> JSONResponse:
|
||||
code = "not_found" if exc.status_code == 404 else "method_not_allowed"
|
||||
return error_response(request, code, "Resource was not found", exc.status_code)
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def unhandled_error(request: Request, exc: Exception) -> JSONResponse:
|
||||
log.exception("request.failed", error_code="internal_error")
|
||||
return error_response(request, "internal_error", "Internal server error", 500)
|
||||
|
||||
|
||||
async def session(request: Request):
|
||||
async for value in request.app.state.db.session():
|
||||
yield value
|
||||
|
||||
|
||||
Session = Annotated[AsyncSession, Depends(session)]
|
||||
|
||||
|
||||
async def bearer_auth(request: Request) -> None:
|
||||
authorization = request.headers.get("Authorization", "")
|
||||
if not authorization.startswith("Bearer "):
|
||||
raise DomainError("unauthorized", 401, "Authentication failed")
|
||||
supplied = authorization.removeprefix("Bearer ").strip()
|
||||
expected = request.app.state.settings.service_token.get_secret_value()
|
||||
if not supplied or not hmac.compare_digest(supplied, expected):
|
||||
raise DomainError("unauthorized", 401, "Authentication failed")
|
||||
|
||||
|
||||
InternalAuth = Annotated[None, Depends(bearer_auth)]
|
||||
|
||||
|
||||
def basic_auth(request: Request) -> None:
|
||||
authorization = request.headers.get("Authorization", "")
|
||||
encoded = (
|
||||
authorization.removeprefix("Basic ").strip() if authorization.startswith("Basic ") else ""
|
||||
)
|
||||
try:
|
||||
decoded = base64.b64decode(encoded, validate=True).decode("utf-8")
|
||||
username, password = decoded.split(":", 1)
|
||||
except (binascii.Error, UnicodeDecodeError, ValueError):
|
||||
raise DomainError("unauthorized", 401, "Authentication failed") from None
|
||||
settings = request.app.state.settings
|
||||
valid_user = hmac.compare_digest(username, settings.callback_username.get_secret_value())
|
||||
valid_password = hmac.compare_digest(password, settings.callback_password.get_secret_value())
|
||||
if not (valid_user and valid_password):
|
||||
raise DomainError("unauthorized", 401, "Authentication failed")
|
||||
|
||||
|
||||
CallbackAuth = Annotated[None, Depends(basic_auth)]
|
||||
|
||||
|
||||
@app.get("/health/live", tags=["health"])
|
||||
async def live() -> dict[str, str]:
|
||||
return {"status": "live"}
|
||||
|
||||
|
||||
@app.get("/health/ready", tags=["health"])
|
||||
async def ready(db: Session) -> JSONResponse:
|
||||
components = {
|
||||
"postgres": "failed",
|
||||
"schema": "failed",
|
||||
"settings": "failed",
|
||||
"template": "failed",
|
||||
}
|
||||
try:
|
||||
await db.execute(text("SELECT 1"))
|
||||
components["postgres"] = "ok"
|
||||
revision = await db.scalar(text("SELECT version_num FROM sms.alembic_version LIMIT 1"))
|
||||
if revision != "0002_seed":
|
||||
raise RuntimeError("unexpected sms schema revision")
|
||||
components["schema"] = "ok"
|
||||
runtime = await load_runtime_settings(db)
|
||||
components["settings"] = "ok"
|
||||
template_count = await db.scalar(
|
||||
select(func.count(SmsTemplate.id)).where(
|
||||
SmsTemplate.code == "auth_otp",
|
||||
SmsTemplate.is_active.is_(True),
|
||||
SmsTemplate.approved_at.is_not(None),
|
||||
(SmsTemplate.sender_name.is_not(None))
|
||||
| (text(":sender <> ''").bindparams(sender=runtime.default_sender_name)),
|
||||
)
|
||||
)
|
||||
if template_count != 1:
|
||||
raise RuntimeError("active approved auth_otp template is missing")
|
||||
components["template"] = "ok"
|
||||
except Exception:
|
||||
log.warning("readiness.failed")
|
||||
failed = "failed" in components.values()
|
||||
return JSONResponse(
|
||||
{"status": "not_ready" if failed else "ready", "components": components},
|
||||
status_code=503 if failed else 200,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/metrics", include_in_schema=False)
|
||||
async def metrics() -> Response:
|
||||
return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST)
|
||||
|
||||
|
||||
@app.post(
|
||||
"/internal/sms/v1/send",
|
||||
response_model=SendResponse,
|
||||
responses={
|
||||
401: {"model": ErrorEnvelope},
|
||||
409: {"model": ErrorEnvelope},
|
||||
422: {"model": ErrorEnvelope},
|
||||
429: {"model": ErrorEnvelope},
|
||||
503: {"model": ErrorEnvelope},
|
||||
},
|
||||
tags=["internal"],
|
||||
)
|
||||
async def send(
|
||||
body: SendRequest,
|
||||
request: Request,
|
||||
db: Session,
|
||||
_auth: InternalAuth,
|
||||
x_request_id: Annotated[str | None, Header(alias="X-Request-ID")] = None,
|
||||
traceparent: Annotated[
|
||||
str | None,
|
||||
Header(pattern=r"^[\da-f]{2}-[\da-f]{32}-[\da-f]{16}-[\da-f]{2}$"),
|
||||
] = None,
|
||||
) -> JSONResponse:
|
||||
result, created = await create_order(
|
||||
db,
|
||||
body,
|
||||
x_request_id,
|
||||
traceparent,
|
||||
request.app.state.settings.service_token.get_secret_value().encode(),
|
||||
)
|
||||
return JSONResponse(result.model_dump(mode="json"), status_code=202 if created else 200)
|
||||
|
||||
|
||||
@app.get(
|
||||
"/internal/sms/v1/messages/{sms_message_id}",
|
||||
response_model=MessageResponse,
|
||||
responses={401: {"model": ErrorEnvelope}, 404: {"model": ErrorEnvelope}},
|
||||
tags=["internal"],
|
||||
)
|
||||
async def message(sms_message_id: uuid.UUID, db: Session, _auth: InternalAuth) -> MessageResponse:
|
||||
return await read_message(db, sms_message_id)
|
||||
|
||||
|
||||
@app.post(
|
||||
"/callbacks/idgtl/sms",
|
||||
status_code=204,
|
||||
responses={401: {"model": ErrorEnvelope}, 422: {"model": ErrorEnvelope}},
|
||||
tags=["callback"],
|
||||
)
|
||||
async def callback(
|
||||
payload: Annotated[list[dict[str, Any]], Body(min_length=1, max_length=1000)],
|
||||
request: Request,
|
||||
db: Session,
|
||||
_auth: CallbackAuth,
|
||||
) -> Response:
|
||||
valid_count = 0
|
||||
for raw in payload:
|
||||
try:
|
||||
item = CallbackItem.model_validate(raw)
|
||||
except ValidationError:
|
||||
CALLBACK_TOTAL.labels("idgtl", "invalid").inc()
|
||||
log.warning("callback.rejected", reason="schema_invalid")
|
||||
continue
|
||||
accepted = await apply_callback(db, item)
|
||||
CALLBACK_TOTAL.labels("idgtl", "accepted" if accepted else "rejected").inc()
|
||||
if accepted:
|
||||
valid_count += 1
|
||||
lag = max(0.0, (datetime_now() - item.status_time).total_seconds())
|
||||
CALLBACK_LAG.labels("idgtl", item.status.lower()).observe(lag)
|
||||
await db.commit()
|
||||
return Response(status_code=204, headers={"X-Callback-Items-Accepted": str(valid_count)})
|
||||
|
||||
|
||||
def datetime_now():
|
||||
from datetime import UTC, datetime
|
||||
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
def run() -> None:
|
||||
settings = get_settings()
|
||||
uvicorn.run(
|
||||
"app.main:app",
|
||||
host="0.0.0.0", # noqa: S104 - required container listener
|
||||
port=settings.api_port,
|
||||
proxy_headers=False,
|
||||
)
|
||||
@@ -0,0 +1,39 @@
|
||||
from prometheus_client import Counter, Gauge, Histogram
|
||||
|
||||
SEND_TOTAL = Counter(
|
||||
"sms_send_total",
|
||||
"Provider send outcomes",
|
||||
("provider", "send_status"),
|
||||
)
|
||||
PROVIDER_LATENCY = Histogram(
|
||||
"sms_provider_request_duration_seconds",
|
||||
"Provider request latency",
|
||||
("provider",),
|
||||
)
|
||||
UNCERTAIN_TOTAL = Counter(
|
||||
"sms_uncertain_total",
|
||||
"Ambiguous provider outcomes",
|
||||
("provider",),
|
||||
)
|
||||
CALLBACK_TOTAL = Counter(
|
||||
"sms_callback_total",
|
||||
"Callback items",
|
||||
("provider", "result"),
|
||||
)
|
||||
CALLBACK_LAG = Histogram(
|
||||
"sms_callback_lag_seconds",
|
||||
"Callback status-to-receipt lag",
|
||||
("provider", "status"),
|
||||
)
|
||||
PENDING_AGE = Gauge(
|
||||
"sms_pending_oldest_age_seconds",
|
||||
"Age of oldest pending message",
|
||||
)
|
||||
JOURNAL_ROWS = Gauge(
|
||||
"sms_journal_rows",
|
||||
"Outbound journal row count",
|
||||
)
|
||||
SETTINGS_VALID = Gauge(
|
||||
"sms_settings_valid",
|
||||
"Whether cached technical settings are valid",
|
||||
)
|
||||
@@ -0,0 +1,161 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import quote, urlsplit, urlunsplit
|
||||
|
||||
import httpx
|
||||
|
||||
from app.db import SendStatus, SmsOutboundMessage
|
||||
from app.domain import ProviderResult
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IdgtlConfig:
|
||||
base_url: str
|
||||
api_key: str
|
||||
callback_url: str
|
||||
callback_username: str
|
||||
callback_password: str
|
||||
connect_timeout_ms: int
|
||||
request_timeout_ms: int
|
||||
callback_enabled: bool
|
||||
|
||||
|
||||
def callback_url_with_credentials(config: IdgtlConfig) -> str:
|
||||
parts = urlsplit(config.callback_url)
|
||||
credentials = (
|
||||
f"{quote(config.callback_username, safe='')}:{quote(config.callback_password, safe='')}"
|
||||
)
|
||||
host = parts.hostname or ""
|
||||
if parts.port:
|
||||
host = f"{host}:{parts.port}"
|
||||
return urlunsplit((parts.scheme, f"{credentials}@{host}", parts.path, parts.query, ""))
|
||||
|
||||
|
||||
def build_payload(message: SmsOutboundMessage, config: IdgtlConfig) -> list[dict[str, object]]:
|
||||
item: dict[str, object] = {
|
||||
"channelType": "SMS",
|
||||
"senderName": message.sender_name,
|
||||
"destination": message.phone_digits,
|
||||
"content": message.body_rendered,
|
||||
"externalMessageId": str(message.id),
|
||||
"ttl": message.message_ttl_sec,
|
||||
}
|
||||
if config.callback_enabled:
|
||||
item["callbackUrl"] = callback_url_with_credentials(config)
|
||||
item["callbackEvents"] = ["delivered", "sent"]
|
||||
return [item]
|
||||
|
||||
|
||||
def classify_response(response: httpx.Response, expected_external_id: str) -> ProviderResult:
|
||||
if response.status_code != 200:
|
||||
if 400 <= response.status_code < 500:
|
||||
return ProviderResult(
|
||||
SendStatus.REJECTED,
|
||||
response.status_code,
|
||||
error_code=f"http_{response.status_code}",
|
||||
error_message="provider_rejected",
|
||||
)
|
||||
return ProviderResult(
|
||||
SendStatus.UNCERTAIN,
|
||||
response.status_code,
|
||||
error_code=f"http_{response.status_code}",
|
||||
error_message="provider_result_uncertain",
|
||||
)
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError:
|
||||
return ProviderResult(
|
||||
SendStatus.REJECTED,
|
||||
200,
|
||||
error_code="malformed_json",
|
||||
error_message="provider_contract_violation",
|
||||
contract_violation=True,
|
||||
)
|
||||
items = payload.get("items") if isinstance(payload, dict) else None
|
||||
if isinstance(payload, dict) and items is None:
|
||||
items = payload.get("messages") or payload.get("results") or payload.get("response")
|
||||
errors = payload.get("errors") if isinstance(payload, dict) else None
|
||||
if errors is not False or not isinstance(items, list) or len(items) != 1:
|
||||
return ProviderResult(
|
||||
SendStatus.REJECTED,
|
||||
200,
|
||||
error_code="invalid_response",
|
||||
error_message="provider_contract_violation",
|
||||
contract_violation=True,
|
||||
)
|
||||
item = items[0]
|
||||
if not isinstance(item, dict):
|
||||
return ProviderResult(
|
||||
SendStatus.REJECTED,
|
||||
200,
|
||||
error_code="invalid_item",
|
||||
error_message="provider_contract_violation",
|
||||
contract_violation=True,
|
||||
)
|
||||
message_uuid = item.get("messageUuid")
|
||||
external_id = item.get("externalMessageId")
|
||||
try:
|
||||
uuid.UUID(str(message_uuid))
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
message_uuid = None
|
||||
valid = item.get("code") == 201 and message_uuid and external_id == expected_external_id
|
||||
if not valid:
|
||||
return ProviderResult(
|
||||
SendStatus.REJECTED,
|
||||
200,
|
||||
error_code=str(item.get("code") or "invalid_item"),
|
||||
error_message="provider_contract_violation",
|
||||
contract_violation=True,
|
||||
)
|
||||
return ProviderResult(
|
||||
SendStatus.ACCEPTED,
|
||||
200,
|
||||
message_uuid=str(message_uuid),
|
||||
external_id=str(external_id),
|
||||
)
|
||||
|
||||
|
||||
class IdgtlClient:
|
||||
def __init__(self, client: httpx.AsyncClient, config: IdgtlConfig) -> None:
|
||||
self.client = client
|
||||
self.config = config
|
||||
|
||||
async def send(self, message: SmsOutboundMessage) -> ProviderResult:
|
||||
timeout = httpx.Timeout(
|
||||
self.config.request_timeout_ms / 1000,
|
||||
connect=self.config.connect_timeout_ms / 1000,
|
||||
)
|
||||
try:
|
||||
headers = {"Authorization": f"Basic {self.config.api_key}"}
|
||||
if message.request_id:
|
||||
headers["X-Request-ID"] = message.request_id
|
||||
if message.traceparent:
|
||||
headers["traceparent"] = message.traceparent
|
||||
response = await self.client.post(
|
||||
f"{self.config.base_url.rstrip('/')}/api/v1/message",
|
||||
headers=headers,
|
||||
json=build_payload(message, self.config),
|
||||
timeout=timeout,
|
||||
)
|
||||
except (httpx.ConnectError, httpx.ConnectTimeout):
|
||||
return ProviderResult(
|
||||
SendStatus.FAILED,
|
||||
error_code="connect_failure",
|
||||
error_message="provider_connect_failure",
|
||||
retry_safe=True,
|
||||
)
|
||||
except (httpx.ReadTimeout, httpx.WriteError, httpx.ReadError, httpx.RemoteProtocolError):
|
||||
return ProviderResult(
|
||||
SendStatus.UNCERTAIN,
|
||||
error_code="ambiguous_transport_failure",
|
||||
error_message="provider_result_uncertain",
|
||||
)
|
||||
except httpx.RequestError:
|
||||
return ProviderResult(
|
||||
SendStatus.UNCERTAIN,
|
||||
error_code="transport_failure",
|
||||
error_message="provider_result_uncertain",
|
||||
)
|
||||
return classify_response(response, str(message.id))
|
||||
@@ -0,0 +1,93 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
|
||||
class SendRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", strict=True)
|
||||
|
||||
idempotency_key: str = Field(min_length=8, max_length=192)
|
||||
template_code: Literal["auth_otp"]
|
||||
locale: Literal["ru"]
|
||||
phone_e164: str = Field(min_length=9, max_length=16)
|
||||
substitutions: dict[str, str | int] = Field(min_length=1, max_length=16)
|
||||
customer_ref: str = Field(min_length=1, max_length=128)
|
||||
message_ttl_sec: int = Field(ge=60, le=86400)
|
||||
|
||||
|
||||
class SendResponse(BaseModel):
|
||||
sms_message_id: uuid.UUID
|
||||
ordered_at: datetime
|
||||
|
||||
|
||||
class MessageResponse(BaseModel):
|
||||
sms_message_id: uuid.UUID
|
||||
ordered_at: datetime
|
||||
updated_at: datetime
|
||||
requester_service: str
|
||||
process: str
|
||||
channel: str
|
||||
provider: str
|
||||
phone_masked: str
|
||||
template_code: str
|
||||
customer_ref: str | None
|
||||
send_status: str
|
||||
delivery_status: str
|
||||
provider_message_id: str | None
|
||||
accepted_at: datetime | None
|
||||
sent_at: datetime | None
|
||||
delivered_at: datetime | None
|
||||
attempt_count: int
|
||||
provider_error_code: str | None
|
||||
|
||||
|
||||
class CallbackItem(BaseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
channel_type: str = Field(validation_alias=AliasChoices("channel_type", "channelType"))
|
||||
message_uuid: str = Field(
|
||||
min_length=1,
|
||||
max_length=128,
|
||||
validation_alias=AliasChoices("message_uuid", "messageUuid"),
|
||||
)
|
||||
external_message_id: str = Field(
|
||||
min_length=1,
|
||||
max_length=128,
|
||||
validation_alias=AliasChoices("external_message_id", "externalMessageId"),
|
||||
)
|
||||
callback_event: str = Field(
|
||||
min_length=1,
|
||||
max_length=32,
|
||||
validation_alias=AliasChoices("callback_event", "callbackEvent", "event"),
|
||||
)
|
||||
status: str = Field(min_length=1, max_length=32)
|
||||
status_time: datetime = Field(validation_alias=AliasChoices("status_time", "statusTime"))
|
||||
error_code: str | None = Field(
|
||||
default=None, validation_alias=AliasChoices("error_code", "errorCode")
|
||||
)
|
||||
parts: int | None = Field(default=None, ge=0)
|
||||
price: Decimal | None = Field(default=None, ge=0)
|
||||
currency: str | None = Field(default=None, min_length=3, max_length=3)
|
||||
|
||||
@field_validator("status_time")
|
||||
@classmethod
|
||||
def require_timezone(cls, value: datetime) -> datetime:
|
||||
if value.tzinfo is None or value.utcoffset() is None:
|
||||
raise ValueError("status_time requires a timezone")
|
||||
return value
|
||||
|
||||
|
||||
class ErrorDetail(BaseModel):
|
||||
code: str
|
||||
message: str
|
||||
request_id: str
|
||||
details: dict[str, Any] | list[dict[str, Any]]
|
||||
|
||||
|
||||
class ErrorEnvelope(BaseModel):
|
||||
error: ErrorDetail
|
||||
@@ -0,0 +1,308 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any, cast
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import func, select, text
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db import (
|
||||
Channel,
|
||||
DeliveryStatus,
|
||||
SendStatus,
|
||||
SmsCallbackEvent,
|
||||
SmsOutboundMessage,
|
||||
SmsSetting,
|
||||
SmsTemplate,
|
||||
)
|
||||
from app.domain import (
|
||||
DomainError,
|
||||
delivery_transition,
|
||||
destination_hmac,
|
||||
normalize_phone,
|
||||
render_template,
|
||||
request_fingerprint,
|
||||
)
|
||||
from app.schemas import CallbackItem, MessageResponse, SendRequest, SendResponse
|
||||
|
||||
log = structlog.get_logger()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimeSettings:
|
||||
default_sender_name: str
|
||||
connect_timeout_ms: int
|
||||
request_timeout_ms: int
|
||||
callback_enabled: bool
|
||||
poll_interval_ms: int
|
||||
lease_seconds: int
|
||||
|
||||
|
||||
SETTING_RULES: dict[str, tuple[type, int | None, int | None]] = {
|
||||
"provider.idgtl.default_sender_name": (str, 1, 64),
|
||||
"provider.idgtl.connect_timeout_ms": (int, 100, 30_000),
|
||||
"provider.idgtl.request_timeout_ms": (int, 1_000, 120_000),
|
||||
"provider.idgtl.callback_enabled": (bool, None, None),
|
||||
"worker.poll_interval_ms": (int, 100, 60_000),
|
||||
"worker.lease_seconds": (int, 10, 600),
|
||||
}
|
||||
|
||||
|
||||
async def load_runtime_settings(db: AsyncSession) -> RuntimeSettings:
|
||||
rows = (
|
||||
await db.execute(select(SmsSetting).where(SmsSetting.setting_key.in_(SETTING_RULES)))
|
||||
).scalars()
|
||||
values = {row.setting_key: row.setting_value for row in rows}
|
||||
if values.keys() != SETTING_RULES.keys():
|
||||
raise RuntimeError("required sms settings are missing")
|
||||
for key, (expected_type, minimum, maximum) in SETTING_RULES.items():
|
||||
value = values[key]
|
||||
if type(value) is not expected_type: # bool is an int subclass
|
||||
raise RuntimeError(f"invalid sms setting type: {key}")
|
||||
if isinstance(value, (int, str)):
|
||||
size = value if isinstance(value, int) else len(value)
|
||||
if minimum is not None and size < minimum:
|
||||
raise RuntimeError(f"sms setting below minimum: {key}")
|
||||
if maximum is not None and size > maximum:
|
||||
raise RuntimeError(f"sms setting above maximum: {key}")
|
||||
sender = str(values["provider.idgtl.default_sender_name"])
|
||||
if sender.startswith("__"):
|
||||
raise RuntimeError("provider sender name is not configured")
|
||||
return RuntimeSettings(
|
||||
default_sender_name=sender,
|
||||
connect_timeout_ms=cast(int, values["provider.idgtl.connect_timeout_ms"]),
|
||||
request_timeout_ms=cast(int, values["provider.idgtl.request_timeout_ms"]),
|
||||
callback_enabled=cast(bool, values["provider.idgtl.callback_enabled"]),
|
||||
poll_interval_ms=cast(int, values["worker.poll_interval_ms"]),
|
||||
lease_seconds=cast(int, values["worker.lease_seconds"]),
|
||||
)
|
||||
|
||||
|
||||
def fingerprint_payload(body: SendRequest, phone_e164: str) -> dict[str, Any]:
|
||||
return {
|
||||
"idempotency_key": body.idempotency_key,
|
||||
"template_code": body.template_code,
|
||||
"locale": body.locale,
|
||||
"phone_e164": phone_e164,
|
||||
"substitutions": body.substitutions,
|
||||
"customer_ref": body.customer_ref,
|
||||
"message_ttl_sec": body.message_ttl_sec,
|
||||
}
|
||||
|
||||
|
||||
def validate_otp_request(body: SendRequest) -> None:
|
||||
code = body.substitutions.get("code")
|
||||
ttl_min = body.substitutions.get("ttl_min")
|
||||
if (
|
||||
not isinstance(code, str)
|
||||
or not code.isascii()
|
||||
or not code.isdigit()
|
||||
or not 4 <= len(code) <= 10
|
||||
or body.message_ttl_sec % 60 != 0
|
||||
or str(ttl_min) != str(body.message_ttl_sec // 60)
|
||||
):
|
||||
raise DomainError(
|
||||
"sms_request_invalid", 422, "OTP substitutions and message TTL are inconsistent"
|
||||
)
|
||||
|
||||
|
||||
def send_response(message: SmsOutboundMessage) -> SendResponse:
|
||||
return SendResponse(sms_message_id=message.id, ordered_at=message.requested_at)
|
||||
|
||||
|
||||
async def existing_order(
|
||||
db: AsyncSession, idempotency_key: str, fingerprint: str
|
||||
) -> SmsOutboundMessage | None:
|
||||
message = await db.scalar(
|
||||
select(SmsOutboundMessage).where(
|
||||
SmsOutboundMessage.requester_service == "keycloak",
|
||||
SmsOutboundMessage.idempotency_key == idempotency_key,
|
||||
)
|
||||
)
|
||||
if message and message.request_fingerprint != fingerprint:
|
||||
raise DomainError("idempotency_key_reused", 409, "Idempotency key was reused")
|
||||
return message
|
||||
|
||||
|
||||
async def enforce_rate_limit(db: AsyncSession, phone_e164: str, destination_key: bytes) -> None:
|
||||
digest = destination_hmac(phone_e164, destination_key)
|
||||
lock_id = int.from_bytes(bytes.fromhex(digest[:16]), byteorder="big", signed=True)
|
||||
await db.execute(text("SELECT pg_advisory_xact_lock(:key)"), {"key": lock_id})
|
||||
since = datetime.now(UTC) - timedelta(minutes=10)
|
||||
count = await db.scalar(
|
||||
select(func.count(SmsOutboundMessage.id)).where(
|
||||
SmsOutboundMessage.requester_service == "keycloak",
|
||||
SmsOutboundMessage.phone_e164 == phone_e164,
|
||||
SmsOutboundMessage.created_at >= since,
|
||||
)
|
||||
)
|
||||
if (count or 0) >= 5:
|
||||
raise DomainError(
|
||||
"rate_limit_exceeded",
|
||||
429,
|
||||
"Rate limit exceeded",
|
||||
{"retry_after": 600},
|
||||
)
|
||||
|
||||
|
||||
async def create_order(
|
||||
db: AsyncSession,
|
||||
body: SendRequest,
|
||||
request_id: str | None,
|
||||
traceparent: str | None,
|
||||
destination_key: bytes,
|
||||
) -> tuple[SendResponse, bool]:
|
||||
validate_otp_request(body)
|
||||
phone_e164, phone_digits, phone_masked = normalize_phone(body.phone_e164)
|
||||
fingerprint = request_fingerprint(fingerprint_payload(body, phone_e164))
|
||||
existing = await existing_order(db, body.idempotency_key, fingerprint)
|
||||
if existing:
|
||||
return send_response(existing), False
|
||||
|
||||
runtime = await load_runtime_settings(db)
|
||||
template = await db.scalar(
|
||||
select(SmsTemplate).where(
|
||||
SmsTemplate.code == body.template_code,
|
||||
SmsTemplate.channel == Channel.SMS,
|
||||
SmsTemplate.locale == body.locale,
|
||||
SmsTemplate.is_active.is_(True),
|
||||
SmsTemplate.approved_at.is_not(None),
|
||||
)
|
||||
)
|
||||
if not template:
|
||||
raise DomainError("sms_service_unavailable", 503, "SMS service is unavailable")
|
||||
sender = template.sender_name or runtime.default_sender_name
|
||||
rendered = render_template(
|
||||
template.body_template, template.placeholders, body.substitutions, template.max_parts
|
||||
)
|
||||
await enforce_rate_limit(db, phone_e164, destination_key)
|
||||
now = datetime.now(UTC)
|
||||
message = SmsOutboundMessage(
|
||||
id=uuid.uuid4(),
|
||||
requested_at=now,
|
||||
updated_at=now,
|
||||
requester_service="keycloak",
|
||||
process="auth_otp",
|
||||
channel="SMS",
|
||||
provider="idgtl",
|
||||
phone_e164=phone_e164,
|
||||
phone_digits=phone_digits,
|
||||
phone_masked=phone_masked,
|
||||
template_id=template.id,
|
||||
template_code=template.code,
|
||||
body_rendered=rendered,
|
||||
substitutions=body.substitutions,
|
||||
send_status=SendStatus.PENDING,
|
||||
delivery_status=DeliveryStatus.UNKNOWN,
|
||||
customer_ref=body.customer_ref,
|
||||
idempotency_key=body.idempotency_key,
|
||||
request_fingerprint=fingerprint,
|
||||
request_id=request_id,
|
||||
traceparent=traceparent,
|
||||
sender_name=sender,
|
||||
message_ttl_sec=body.message_ttl_sec,
|
||||
attempt_count=0,
|
||||
next_attempt_at=now,
|
||||
)
|
||||
db.add(message)
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
concurrent = await existing_order(db, body.idempotency_key, fingerprint)
|
||||
if concurrent:
|
||||
return send_response(concurrent), False
|
||||
raise
|
||||
return send_response(message), True
|
||||
|
||||
|
||||
def message_response(message: SmsOutboundMessage) -> MessageResponse:
|
||||
return MessageResponse(
|
||||
sms_message_id=message.id,
|
||||
ordered_at=message.requested_at,
|
||||
updated_at=message.updated_at,
|
||||
requester_service=message.requester_service,
|
||||
process=message.process,
|
||||
channel=message.channel,
|
||||
provider=message.provider,
|
||||
phone_masked=message.phone_masked,
|
||||
template_code=message.template_code,
|
||||
customer_ref=message.customer_ref,
|
||||
send_status=message.send_status.value,
|
||||
delivery_status=message.delivery_status.value,
|
||||
provider_message_id=message.provider_message_id,
|
||||
accepted_at=message.accepted_at,
|
||||
sent_at=message.sent_at,
|
||||
delivered_at=message.delivered_at,
|
||||
attempt_count=message.attempt_count,
|
||||
provider_error_code=message.provider_error_code,
|
||||
)
|
||||
|
||||
|
||||
async def read_message(db: AsyncSession, message_id: uuid.UUID) -> MessageResponse:
|
||||
message = await db.scalar(
|
||||
select(SmsOutboundMessage).where(
|
||||
SmsOutboundMessage.id == message_id,
|
||||
SmsOutboundMessage.requester_service == "keycloak",
|
||||
)
|
||||
)
|
||||
if not message:
|
||||
raise DomainError("not_found", 404, "Resource was not found")
|
||||
return message_response(message)
|
||||
|
||||
|
||||
async def apply_callback(db: AsyncSession, item: CallbackItem) -> bool:
|
||||
if item.channel_type.upper() != "SMS":
|
||||
log.warning("callback.rejected", reason="wrong_channel")
|
||||
return False
|
||||
message = await db.scalar(
|
||||
select(SmsOutboundMessage)
|
||||
.where(
|
||||
SmsOutboundMessage.provider == "idgtl",
|
||||
SmsOutboundMessage.provider_message_id == item.message_uuid,
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
if not message or item.external_message_id != str(message.id):
|
||||
digest = hashlib.sha256(item.message_uuid.encode()).hexdigest()[:16]
|
||||
log.warning(
|
||||
"callback.rejected", reason="unknown_or_conflicting_message", message_hash=digest
|
||||
)
|
||||
return False
|
||||
target = delivery_transition(message.delivery_status, item.status)
|
||||
if target is None:
|
||||
log.warning("callback.rejected", reason="unknown_status", sms_message_id=str(message.id))
|
||||
return False
|
||||
inserted = await db.scalar(
|
||||
pg_insert(SmsCallbackEvent)
|
||||
.values(
|
||||
id=uuid.uuid4(),
|
||||
message_uuid=item.message_uuid,
|
||||
callback_event=item.callback_event.lower(),
|
||||
status=item.status.lower(),
|
||||
status_time=item.status_time,
|
||||
)
|
||||
.on_conflict_do_nothing(constraint="uq_callback_event")
|
||||
.returning(SmsCallbackEvent.id)
|
||||
)
|
||||
if inserted is None:
|
||||
return True
|
||||
now = datetime.now(UTC)
|
||||
message.delivery_status = target
|
||||
message.callback_last_at = now
|
||||
message.updated_at = now
|
||||
message.provider_error_code = item.error_code
|
||||
message.parts = item.parts if item.parts is not None else message.parts
|
||||
message.price = item.price if item.price is not None else message.price
|
||||
message.currency = item.currency if item.currency is not None else message.currency
|
||||
if target == DeliveryStatus.SENT and message.sent_at is None:
|
||||
message.sent_at = item.status_time
|
||||
elif target == DeliveryStatus.DELIVERED and message.delivered_at is None:
|
||||
message.delivered_at = item.status_time
|
||||
return True
|
||||
@@ -0,0 +1,53 @@
|
||||
from functools import lru_cache
|
||||
|
||||
from pydantic import AnyHttpUrl, Field, SecretStr, field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=None, extra="ignore")
|
||||
|
||||
database_url: str = Field(alias="SMS_DATABASE_URL")
|
||||
service_token: SecretStr = Field(alias="SMS_SERVICE_TOKEN", min_length=32)
|
||||
idgtl_base_url: AnyHttpUrl = Field(
|
||||
default=AnyHttpUrl("https://direct.i-dgtl.ru"), alias="IDGTL_SMS_BASE_URL"
|
||||
)
|
||||
idgtl_api_key: SecretStr | None = Field(default=None, alias="IDGTL_SMS_API_KEY")
|
||||
callback_public_url: AnyHttpUrl = Field(alias="IDGTL_SMS_CALLBACK_PUBLIC_URL")
|
||||
callback_username: SecretStr = Field(alias="IDGTL_SMS_CALLBACK_USERNAME")
|
||||
callback_password: SecretStr = Field(alias="IDGTL_SMS_CALLBACK_PASSWORD")
|
||||
log_level: str = Field(default="INFO", alias="LOG_LEVEL")
|
||||
api_port: int = Field(default=8080, alias="SMS_API_PORT", ge=1, le=65535)
|
||||
|
||||
@field_validator(
|
||||
"service_token",
|
||||
"callback_username",
|
||||
"callback_password",
|
||||
)
|
||||
@classmethod
|
||||
def reject_placeholders(cls, value: SecretStr) -> SecretStr:
|
||||
raw = value.get_secret_value().strip()
|
||||
if not raw or raw.lower() in {"changeme", "secret", "token", "<secret>"}:
|
||||
raise ValueError("secret is missing or is a placeholder")
|
||||
return value
|
||||
|
||||
@field_validator("idgtl_api_key")
|
||||
@classmethod
|
||||
def reject_api_key_placeholder(cls, value: SecretStr | None) -> SecretStr | None:
|
||||
if value is None:
|
||||
return None
|
||||
return cls.reject_placeholders(value)
|
||||
|
||||
@field_validator("callback_public_url")
|
||||
@classmethod
|
||||
def callback_must_be_https(cls, value: AnyHttpUrl) -> AnyHttpUrl:
|
||||
if value.scheme != "https":
|
||||
raise ValueError("callback URL must use HTTPS")
|
||||
if value.username or value.password:
|
||||
raise ValueError("callback URL must not contain credentials")
|
||||
return value
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
@@ -0,0 +1,217 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import random
|
||||
import signal
|
||||
import time
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import httpx
|
||||
import structlog
|
||||
from sqlalchemy import and_, func, or_, select, update
|
||||
|
||||
from app.db import Database, SendStatus, SmsOutboundMessage
|
||||
from app.metrics import (
|
||||
JOURNAL_ROWS,
|
||||
PENDING_AGE,
|
||||
PROVIDER_LATENCY,
|
||||
SEND_TOTAL,
|
||||
SETTINGS_VALID,
|
||||
UNCERTAIN_TOTAL,
|
||||
)
|
||||
from app.provider import IdgtlClient, IdgtlConfig
|
||||
from app.service import RuntimeSettings, load_runtime_settings
|
||||
from app.settings import Settings, get_settings
|
||||
|
||||
log = structlog.get_logger()
|
||||
MAX_CONNECT_ATTEMPTS = 3
|
||||
|
||||
|
||||
def configure_logging(level: str) -> None:
|
||||
logging.basicConfig(level=level, format="%(message)s")
|
||||
structlog.configure(
|
||||
processors=[
|
||||
structlog.processors.TimeStamper(fmt="iso", utc=True, key="timestamp"),
|
||||
structlog.stdlib.add_log_level,
|
||||
structlog.processors.JSONRenderer(),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
async def reconcile_expired_leases(db: Database) -> int:
|
||||
now = datetime.now(UTC)
|
||||
async with db.sessions.begin() as session:
|
||||
result = await session.execute(
|
||||
update(SmsOutboundMessage)
|
||||
.where(
|
||||
SmsOutboundMessage.send_status == SendStatus.PENDING,
|
||||
SmsOutboundMessage.attempt_count > 0,
|
||||
SmsOutboundMessage.worker_locked_until < now,
|
||||
)
|
||||
.values(
|
||||
send_status=SendStatus.UNCERTAIN,
|
||||
worker_locked_until=None,
|
||||
next_attempt_at=None,
|
||||
updated_at=now,
|
||||
provider_error_code="worker_lease_expired",
|
||||
provider_error_message="provider_result_uncertain",
|
||||
)
|
||||
.returning(SmsOutboundMessage.id)
|
||||
)
|
||||
ids = list(result.scalars())
|
||||
for message_id in ids:
|
||||
SEND_TOTAL.labels("idgtl", SendStatus.UNCERTAIN.value).inc()
|
||||
UNCERTAIN_TOTAL.labels("idgtl").inc()
|
||||
log.error("worker.lease_expired", sms_message_id=str(message_id))
|
||||
return len(ids)
|
||||
|
||||
|
||||
async def lease_message(db: Database, runtime: RuntimeSettings) -> SmsOutboundMessage | None:
|
||||
now = datetime.now(UTC)
|
||||
eligible = or_(
|
||||
and_(
|
||||
SmsOutboundMessage.send_status == SendStatus.PENDING,
|
||||
SmsOutboundMessage.attempt_count == 0,
|
||||
),
|
||||
and_(
|
||||
SmsOutboundMessage.send_status == SendStatus.FAILED,
|
||||
SmsOutboundMessage.attempt_count < MAX_CONNECT_ATTEMPTS,
|
||||
),
|
||||
)
|
||||
async with db.sessions.begin() as session:
|
||||
message = await session.scalar(
|
||||
select(SmsOutboundMessage)
|
||||
.where(
|
||||
eligible,
|
||||
SmsOutboundMessage.next_attempt_at <= now,
|
||||
or_(
|
||||
SmsOutboundMessage.worker_locked_until.is_(None),
|
||||
SmsOutboundMessage.worker_locked_until < now,
|
||||
),
|
||||
)
|
||||
.order_by(SmsOutboundMessage.next_attempt_at, SmsOutboundMessage.created_at)
|
||||
.with_for_update(skip_locked=True)
|
||||
.limit(1)
|
||||
)
|
||||
if message:
|
||||
message.send_status = SendStatus.PENDING
|
||||
message.attempt_count += 1
|
||||
message.last_attempt_at = now
|
||||
message.worker_locked_until = now + timedelta(seconds=runtime.lease_seconds)
|
||||
message.updated_at = now
|
||||
return message
|
||||
|
||||
|
||||
async def save_result(db: Database, message_id, result, attempt_count: int) -> None:
|
||||
now = datetime.now(UTC)
|
||||
status = result.send_status
|
||||
next_attempt = None
|
||||
if result.retry_safe and attempt_count < MAX_CONNECT_ATTEMPTS:
|
||||
next_attempt = now + timedelta(seconds=(2**attempt_count) + random.uniform(0, 1)) # noqa: S311
|
||||
async with db.sessions.begin() as session:
|
||||
values = {
|
||||
"send_status": status,
|
||||
"provider_http_status": result.http_status,
|
||||
"provider_message_id": result.message_uuid,
|
||||
"provider_external_id": result.external_id,
|
||||
"provider_error_code": result.error_code,
|
||||
"provider_error_message": result.error_message,
|
||||
"worker_locked_until": None,
|
||||
"next_attempt_at": next_attempt,
|
||||
"updated_at": now,
|
||||
}
|
||||
if status == SendStatus.ACCEPTED:
|
||||
values["accepted_at"] = now
|
||||
await session.execute(
|
||||
update(SmsOutboundMessage)
|
||||
.where(
|
||||
SmsOutboundMessage.id == message_id,
|
||||
SmsOutboundMessage.send_status == SendStatus.PENDING,
|
||||
SmsOutboundMessage.attempt_count == attempt_count,
|
||||
)
|
||||
.values(**values)
|
||||
)
|
||||
SEND_TOTAL.labels("idgtl", status.value).inc()
|
||||
if status == SendStatus.UNCERTAIN:
|
||||
UNCERTAIN_TOTAL.labels("idgtl").inc()
|
||||
if result.contract_violation:
|
||||
log.error("provider.contract_violation", sms_message_id=str(message_id))
|
||||
|
||||
|
||||
def provider_config(settings: Settings, runtime: RuntimeSettings) -> IdgtlConfig:
|
||||
if settings.idgtl_api_key is None:
|
||||
raise RuntimeError("IDGTL_SMS_API_KEY is required by sms-worker")
|
||||
return IdgtlConfig(
|
||||
base_url=str(settings.idgtl_base_url),
|
||||
api_key=settings.idgtl_api_key.get_secret_value(),
|
||||
callback_url=str(settings.callback_public_url),
|
||||
callback_username=settings.callback_username.get_secret_value(),
|
||||
callback_password=settings.callback_password.get_secret_value(),
|
||||
connect_timeout_ms=runtime.connect_timeout_ms,
|
||||
request_timeout_ms=runtime.request_timeout_ms,
|
||||
callback_enabled=runtime.callback_enabled,
|
||||
)
|
||||
|
||||
|
||||
async def update_queue_metrics(db: Database) -> None:
|
||||
async with db.sessions() as session:
|
||||
oldest = await session.scalar(
|
||||
select(func.min(SmsOutboundMessage.created_at)).where(
|
||||
SmsOutboundMessage.send_status == SendStatus.PENDING
|
||||
)
|
||||
)
|
||||
count = await session.scalar(select(func.count(SmsOutboundMessage.id)))
|
||||
age = max(0.0, (datetime.now(UTC) - oldest).total_seconds()) if oldest else 0.0
|
||||
PENDING_AGE.set(age)
|
||||
JOURNAL_ROWS.set(count or 0)
|
||||
|
||||
|
||||
async def worker_loop(stop: asyncio.Event) -> None:
|
||||
settings = get_settings()
|
||||
configure_logging(settings.log_level)
|
||||
db = Database(settings.database_url)
|
||||
async with httpx.AsyncClient() as http:
|
||||
try:
|
||||
while not stop.is_set():
|
||||
try:
|
||||
await reconcile_expired_leases(db)
|
||||
async with db.sessions() as session:
|
||||
runtime = await load_runtime_settings(session)
|
||||
SETTINGS_VALID.set(1)
|
||||
message = await lease_message(db, runtime)
|
||||
if message is None:
|
||||
await update_queue_metrics(db)
|
||||
await asyncio.wait_for(stop.wait(), timeout=runtime.poll_interval_ms / 1000)
|
||||
continue
|
||||
client = IdgtlClient(http, provider_config(settings, runtime))
|
||||
started = time.monotonic()
|
||||
result = await client.send(message)
|
||||
PROVIDER_LATENCY.labels("idgtl").observe(time.monotonic() - started)
|
||||
await save_result(db, message.id, result, message.attempt_count)
|
||||
except TimeoutError:
|
||||
continue
|
||||
except Exception:
|
||||
SETTINGS_VALID.set(0)
|
||||
log.exception("worker.iteration_failed")
|
||||
try:
|
||||
await asyncio.wait_for(stop.wait(), timeout=5)
|
||||
except TimeoutError:
|
||||
pass
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
|
||||
def run() -> None:
|
||||
stop = asyncio.Event()
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
for name in (signal.SIGINT, signal.SIGTERM):
|
||||
try:
|
||||
loop.add_signal_handler(name, stop.set)
|
||||
except NotImplementedError:
|
||||
pass
|
||||
try:
|
||||
loop.run_until_complete(worker_loop(stop))
|
||||
finally:
|
||||
loop.close()
|
||||
Reference in New Issue
Block a user