Проект разделен на два репозитория

This commit is contained in:
mi
2026-08-14 15:42:45 +03:00
parent e06a77ee1d
commit bbef7a30c9
521 changed files with 2597 additions and 2302 deletions
@@ -0,0 +1,20 @@
FROM python:3.12-slim AS builder
WORKDIR /build
RUN pip install --no-cache-dir --upgrade pip build
COPY pyproject.toml ./
COPY app ./app
RUN python -m build --wheel
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
RUN addgroup --system --gid 10001 han && adduser --system --uid 10001 --ingroup han han
WORKDIR /app
COPY --from=builder /build/dist/*.whl /tmp/
RUN pip install --no-cache-dir /tmp/*.whl && rm -f /tmp/*.whl
COPY alembic.ini ./
COPY migrations ./migrations
COPY --chmod=0555 container-entrypoint.sh /usr/local/bin/han-container-entrypoint
USER 10001:10001
EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/han-container-entrypoint"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080", "--no-proxy-headers"]
@@ -0,0 +1,38 @@
[alembic]
script_location = migrations
prepend_sys_path = .
version_table_schema = sms
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
@@ -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,47 @@
from __future__ import annotations
import re
from collections.abc import Mapping
from typing import Any
REDACTED = "[REDACTED]"
_SENSITIVE_KEY = re.compile(
r"(authorization|cookie|password|passwd|secret|token|api[_-]?key|"
r"database[_-]?url|redis[_-]?url|dsn|callback[_-]?url)",
re.IGNORECASE,
)
_URI_USERINFO = re.compile(r"(?P<scheme>[a-z][a-z0-9+.-]*://)[^/@\s]+@", re.IGNORECASE)
_QUERY_SECRET = re.compile(
r"(?P<prefix>[?&](?:token|access_token|api_key|key|secret|password)=)[^&#\s]+",
re.IGNORECASE,
)
_AUTH_VALUE = re.compile(r"\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+", re.IGNORECASE)
def sanitize_text(value: str) -> str:
value = _URI_USERINFO.sub(r"\g<scheme>[REDACTED]@", value)
value = _QUERY_SECRET.sub(r"\g<prefix>[REDACTED]", value)
return _AUTH_VALUE.sub(r"\1 [REDACTED]", value)
def sanitize_value(value: Any) -> Any:
if isinstance(value, str):
return sanitize_text(value)
if isinstance(value, Mapping):
return {
str(key): REDACTED if _SENSITIVE_KEY.search(str(key)) else sanitize_value(item)
for key, item in value.items()
}
if isinstance(value, list):
return [sanitize_value(item) for item in value]
if isinstance(value, tuple):
return tuple(sanitize_value(item) for item in value)
return value
def redact_event(
_logger: Any,
_method_name: str,
event_dict: dict[str, Any],
) -> dict[str, Any]:
return sanitize_value(event_dict)
@@ -0,0 +1,338 @@
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.logging_security import redact_event
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
from app.telemetry import add_trace_context, init_telemetry, instrument_fastapi
log = structlog.get_logger()
def configure_logging(level: str) -> None:
logging.basicConfig(level=level, format="%(message)s")
structlog.configure(
processors=[
structlog.contextvars.merge_contextvars,
add_trace_context,
redact_event,
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()
telemetry = init_telemetry()
configure_logging(settings.log_level)
app.state.settings = settings
app.state.db = Database(settings.database_url)
try:
yield
finally:
await app.state.db.close()
if telemetry:
telemetry.shutdown()
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,
**{"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",
route=getattr(request.scope.get("route"), "path", request.url.path),
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.error(
"request.failed",
error_code="internal_error",
error_type=type(exc).__name__,
)
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)
instrument_fastapi(app)
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,54 @@
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)
metrics_port: int = Field(default=9464, alias="SMS_METRICS_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,102 @@
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import Any
from fastapi import FastAPI
from opentelemetry import metrics, trace
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
from opentelemetry.propagate import set_global_textmap
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.sdk.trace.sampling import ALWAYS_ON
from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
@dataclass(slots=True)
class TelemetryRuntime:
tracer_provider: TracerProvider
meter_provider: MeterProvider
def shutdown(self) -> None:
self.meter_provider.shutdown()
self.tracer_provider.shutdown()
_runtime: TelemetryRuntime | None = None
def _resource(service_name: str) -> Resource:
return Resource.create(
{
"service.name": service_name,
"service.namespace": "han-chat",
"service.version": os.getenv("RELEASE_VERSION", "unknown"),
"deployment.environment": os.getenv("APP_ENV", "production-like"),
}
)
def init_telemetry(service_name: str | None = None) -> TelemetryRuntime | None:
global _runtime
if _runtime is not None:
return _runtime
endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "").strip()
if not endpoint:
return None
resource = _resource(service_name or os.getenv("OTEL_SERVICE_NAME", "sms-service"))
insecure = endpoint.startswith("http://")
set_global_textmap(TraceContextTextMapPropagator())
tracer_provider = TracerProvider(resource=resource, sampler=ALWAYS_ON)
tracer_provider.add_span_processor(
BatchSpanProcessor(
OTLPSpanExporter(endpoint=endpoint, insecure=insecure, timeout=3),
max_queue_size=2048,
schedule_delay_millis=5000,
max_export_batch_size=512,
export_timeout_millis=3000,
)
)
trace.set_tracer_provider(tracer_provider)
metric_reader = PeriodicExportingMetricReader(
OTLPMetricExporter(endpoint=endpoint, insecure=insecure, timeout=3),
export_interval_millis=30000,
export_timeout_millis=3000,
)
meter_provider = MeterProvider(resource=resource, metric_readers=[metric_reader])
metrics.set_meter_provider(meter_provider)
HTTPXClientInstrumentor().instrument()
SQLAlchemyInstrumentor().instrument(enable_commenter=False)
_runtime = TelemetryRuntime(tracer_provider, meter_provider)
return _runtime
def instrument_fastapi(app: FastAPI) -> None:
FastAPIInstrumentor.instrument_app(
app,
excluded_urls="/health/live,/health/ready",
)
def add_trace_context(
_logger: Any,
_method_name: str,
event_dict: dict[str, Any],
) -> dict[str, Any]:
context = trace.get_current_span().get_span_context()
if context.is_valid:
event_dict["trace_id"] = format(context.trace_id, "032x")
event_dict["span_id"] = format(context.span_id, "016x")
return event_dict
@@ -0,0 +1,252 @@
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 opentelemetry import trace
from prometheus_client import start_http_server
from sqlalchemy import and_, func, or_, select, update
from app.db import Database, SendStatus, SmsOutboundMessage
from app.logging_security import redact_event
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
from app.telemetry import add_trace_context, init_telemetry
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.contextvars.merge_contextvars,
add_trace_context,
redact_event,
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()
telemetry = init_telemetry("sms-worker")
configure_logging(settings.log_level)
structlog.contextvars.bind_contextvars(**{"service.name": "sms-worker"})
metrics_server, metrics_thread = start_http_server(
settings.metrics_port,
addr="0.0.0.0", # noqa: S104 - internal Docker-network listener
)
tracer = trace.get_tracer("han.sms.worker")
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)
claim_started_ns = time.time_ns()
message = await lease_message(db, runtime)
claim_finished_ns = time.time_ns()
if message is None:
await update_queue_metrics(db)
await asyncio.wait_for(stop.wait(), timeout=runtime.poll_interval_ms / 1000)
continue
process_span = tracer.start_span("sms.process", start_time=claim_started_ns)
with trace.use_span(process_span, end_on_exit=True):
claim_span = tracer.start_span("sms.claim", start_time=claim_started_ns)
claim_span.set_attribute("sms.claimed", True)
claim_span.end(end_time=claim_finished_ns)
span = trace.get_current_span()
span.set_attribute("messaging.operation.name", "send")
span.set_attribute("messaging.system", "idgtl")
span.set_attribute("sms.attempt", message.attempt_count)
client = IdgtlClient(http, provider_config(settings, runtime))
with tracer.start_as_current_span("sms.provider"):
started = time.monotonic()
result = await client.send(message)
PROVIDER_LATENCY.labels("idgtl").observe(time.monotonic() - started)
span.set_attribute("sms.outcome", result.send_status.value)
with tracer.start_as_current_span("sms.save_result"):
await save_result(db, message.id, result, message.attempt_count)
except TimeoutError:
continue
except Exception as exc:
SETTINGS_VALID.set(0)
log.error(
"worker.iteration_failed",
error_type=type(exc).__name__,
)
try:
await asyncio.wait_for(stop.wait(), timeout=5)
except TimeoutError:
pass
finally:
await db.close()
metrics_server.shutdown()
metrics_thread.join(timeout=5)
if telemetry:
telemetry.shutdown()
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()
@@ -0,0 +1,23 @@
#!/bin/sh
set -eu
for name in ${HAN_SECRET_VARS:-}; do
case "$name" in
""|[0-9]*|*[!A-Z0-9_]*)
echo "container secrets: invalid variable name" >&2
exit 64
;;
*) ;;
esac
eval "file=\${${name}_FILE:-}"
if [ -z "$file" ] || [ ! -r "$file" ]; then
echo "container secrets: missing file for $name" >&2
exit 66
fi
value=$(cat "$file")
export "$name=$value"
unset "${name}_FILE"
done
unset HAN_SECRET_VARS
exec "$@"
@@ -0,0 +1,56 @@
from __future__ import annotations
import asyncio
import os
from logging.config import fileConfig
from alembic import context
from app.db import Base, create_postgres_engine
config = context.config
if config.config_file_name:
fileConfig(config.config_file_name)
database_url = os.environ["SMS_DATABASE_URL"]
if database_url.startswith("postgresql://"):
database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
config.set_main_option("sqlalchemy.url", database_url.replace("%", "%%"))
target_metadata = Base.metadata
def run_migrations_offline() -> None:
context.configure(
url=config.get_main_option("sqlalchemy.url"),
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
version_table_schema="sms",
include_schemas=True,
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection) -> None:
context.configure(
connection=connection,
target_metadata=target_metadata,
version_table_schema="sms",
include_schemas=True,
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
connectable = create_postgres_engine(database_url)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
if context.is_offline_mode():
run_migrations_offline()
else:
asyncio.run(run_async_migrations())
@@ -0,0 +1,231 @@
"""Create SMS journal schema objects.
Revision ID: 0001_initial
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision = "0001_initial"
down_revision = None
branch_labels = None
depends_on = None
SCHEMA = "sms"
channel = postgresql.ENUM("SMS", name="sms_channel", schema=SCHEMA, create_type=False)
send_status = postgresql.ENUM(
"pending",
"accepted",
"rejected",
"failed",
"uncertain",
"skipped",
name="sms_send_status",
schema=SCHEMA,
create_type=False,
)
delivery_status = postgresql.ENUM(
"unknown",
"sent",
"delivered",
"undelivered",
"unsent",
name="sms_delivery_status",
schema=SCHEMA,
create_type=False,
)
def upgrade() -> None:
bind = op.get_bind()
postgresql.ENUM("SMS", name="sms_channel", schema=SCHEMA).create(bind)
postgresql.ENUM(
"pending",
"accepted",
"rejected",
"failed",
"uncertain",
"skipped",
name="sms_send_status",
schema=SCHEMA,
).create(bind)
postgresql.ENUM(
"unknown",
"sent",
"delivered",
"undelivered",
"unsent",
name="sms_delivery_status",
schema=SCHEMA,
).create(bind)
op.create_table(
"sms_template",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("code", sa.String(64), nullable=False),
sa.Column("channel", channel, nullable=False),
sa.Column("locale", sa.String(16), nullable=False),
sa.Column("version", sa.Integer(), nullable=False),
sa.Column("body_template", sa.Text(), nullable=False),
sa.Column("placeholders", postgresql.JSONB(), nullable=False),
sa.Column("sender_name", sa.String(64)),
sa.Column("max_parts", sa.SmallInteger(), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("approved_at", sa.DateTime(timezone=True)),
sa.Column(
"created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
),
sa.Column("created_by", sa.String(64), nullable=False),
sa.CheckConstraint("version > 0", name="ck_template_version_positive"),
sa.CheckConstraint("max_parts BETWEEN 1 AND 10", name="ck_template_max_parts"),
sa.UniqueConstraint("code", "channel", "locale", "version", name="uq_template_version"),
schema=SCHEMA,
)
op.create_index(
"uq_template_active",
"sms_template",
["code", "channel", "locale"],
unique=True,
schema=SCHEMA,
postgresql_where=sa.text("is_active"),
)
op.create_table(
"sms_setting",
sa.Column("setting_key", sa.String(128), primary_key=True),
sa.Column("setting_value", postgresql.JSONB(), nullable=False),
sa.Column("value_type", sa.String(16), nullable=False),
sa.Column("description", sa.Text(), nullable=False),
sa.Column(
"updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
),
sa.CheckConstraint(
"value_type IN ('string','integer','boolean')", name="ck_setting_value_type"
),
schema=SCHEMA,
)
op.create_table(
"sms_outbound_message",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column(
"created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
),
sa.Column("requested_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("accepted_at", sa.DateTime(timezone=True)),
sa.Column("sent_at", sa.DateTime(timezone=True)),
sa.Column("delivered_at", sa.DateTime(timezone=True)),
sa.Column(
"updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
),
sa.Column("requester_service", sa.String(64), nullable=False),
sa.Column("process", sa.String(64), nullable=False),
sa.Column("channel", sa.String(16), nullable=False),
sa.Column("provider", sa.String(32), nullable=False),
sa.Column("phone_e164", sa.String(16), nullable=False),
sa.Column("phone_digits", sa.String(15), nullable=False),
sa.Column("phone_masked", sa.String(32), nullable=False),
sa.Column(
"template_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey(f"{SCHEMA}.sms_template.id"),
nullable=False,
),
sa.Column("template_code", sa.String(64), nullable=False),
sa.Column("body_rendered", sa.Text(), nullable=False),
sa.Column("substitutions", postgresql.JSONB(), nullable=False),
sa.Column("send_status", send_status, nullable=False),
sa.Column("delivery_status", delivery_status, nullable=False),
sa.Column("provider_message_id", sa.String(128)),
sa.Column("provider_external_id", sa.String(128)),
sa.Column("customer_ref", sa.String(128)),
sa.Column("idempotency_key", sa.String(192), nullable=False),
sa.Column("request_fingerprint", sa.String(64), nullable=False),
sa.Column("request_id", sa.String(128)),
sa.Column("traceparent", sa.String(55)),
sa.Column("provider_http_status", sa.Integer()),
sa.Column("provider_error_code", sa.String(64)),
sa.Column("provider_error_message", sa.String(256)),
sa.Column("sender_name", sa.String(64), nullable=False),
sa.Column("message_ttl_sec", sa.Integer()),
sa.Column("attempt_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("last_attempt_at", sa.DateTime(timezone=True)),
sa.Column("next_attempt_at", sa.DateTime(timezone=True)),
sa.Column("worker_locked_until", sa.DateTime(timezone=True)),
sa.Column("parts", sa.Integer()),
sa.Column("price", sa.Numeric(14, 4)),
sa.Column("currency", sa.String(3)),
sa.Column("callback_last_at", sa.DateTime(timezone=True)),
sa.CheckConstraint("channel = 'SMS'", name="ck_outbound_channel"),
sa.CheckConstraint("provider = 'idgtl'", name="ck_outbound_provider"),
sa.CheckConstraint("process = 'auth_otp'", name="ck_outbound_process"),
sa.CheckConstraint("message_ttl_sec BETWEEN 60 AND 86400", name="ck_outbound_ttl"),
sa.CheckConstraint("attempt_count >= 0", name="ck_outbound_attempts"),
sa.UniqueConstraint("requester_service", "idempotency_key", name="uq_outbound_idempotency"),
schema=SCHEMA,
)
op.create_index(
"uq_outbound_provider_message",
"sms_outbound_message",
["provider", "provider_message_id"],
unique=True,
schema=SCHEMA,
postgresql_where=sa.text("provider_message_id IS NOT NULL"),
)
op.create_index(
"ix_outbound_phone_created",
"sms_outbound_message",
["phone_e164", sa.text("created_at DESC")],
schema=SCHEMA,
)
op.create_index(
"ix_outbound_requester_process_created",
"sms_outbound_message",
["requester_service", "process", sa.text("created_at DESC")],
schema=SCHEMA,
)
op.create_index(
"ix_outbound_customer_ref", "sms_outbound_message", ["customer_ref"], schema=SCHEMA
)
op.create_index(
"ix_outbound_send_created",
"sms_outbound_message",
["send_status", "created_at"],
schema=SCHEMA,
)
op.create_index(
"ix_outbound_delivery_updated",
"sms_outbound_message",
["delivery_status", "updated_at"],
schema=SCHEMA,
)
op.create_table(
"sms_callback_event",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("message_uuid", sa.String(128), nullable=False),
sa.Column("callback_event", sa.String(32), nullable=False),
sa.Column("status", sa.String(32), nullable=False),
sa.Column("status_time", sa.DateTime(timezone=True), nullable=False),
sa.Column(
"received_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
),
sa.UniqueConstraint(
"message_uuid", "callback_event", "status", "status_time", name="uq_callback_event"
),
schema=SCHEMA,
)
def downgrade() -> None:
op.drop_table("sms_callback_event", schema=SCHEMA)
op.drop_table("sms_outbound_message", schema=SCHEMA)
op.drop_table("sms_setting", schema=SCHEMA)
op.drop_table("sms_template", schema=SCHEMA)
delivery_status.drop(op.get_bind())
send_status.drop(op.get_bind())
channel.drop(op.get_bind())
@@ -0,0 +1,108 @@
"""Seed versioned technical settings and OTP template placeholder.
Revision ID: 0002_seed
"""
import uuid
import sqlalchemy as sa
from alembic import op
revision = "0002_seed"
down_revision = "0001_initial"
branch_labels = None
depends_on = None
TEMPLATE_ID = uuid.UUID("5ac2a77e-590c-4b24-87d8-baa0f1240cd1")
def upgrade() -> None:
bind = op.get_bind()
bind.execute(
sa.text(
"""
INSERT INTO sms.sms_template (
id, code, channel, locale, version, body_template, placeholders,
sender_name, max_parts, is_active, approved_at, created_by
) VALUES (
:id, 'auth_otp', 'SMS', 'ru', 1,
'Код входа в HAN Chat: {code}. Действителен {ttl_min} мин.',
'["code","ttl_min"]'::jsonb, NULL, 1, true, NULL, 'migration'
)
ON CONFLICT (code, channel, locale, version) DO NOTHING
"""
),
{"id": TEMPLATE_ID},
)
settings = (
(
"provider.idgtl.default_sender_name",
'"__SET_ME_AFTER_PROVIDER_APPROVAL__"',
"string",
"Provider-approved default sender name",
),
(
"provider.idgtl.connect_timeout_ms",
"3000",
"integer",
"Direct connection timeout in milliseconds",
),
(
"provider.idgtl.request_timeout_ms",
"70000",
"integer",
"Direct total request timeout in milliseconds",
),
(
"provider.idgtl.callback_enabled",
"true",
"boolean",
"Include delivery callback in provider requests",
),
(
"worker.poll_interval_ms",
"500",
"integer",
"Queue polling interval in milliseconds",
),
(
"worker.lease_seconds",
"90",
"integer",
"Exclusive provider-call lease duration",
),
)
for key, value, value_type, description in settings:
bind.execute(
sa.text(
"""
INSERT INTO sms.sms_setting (
setting_key, setting_value, value_type, description
) VALUES (:key, CAST(:value AS jsonb), :value_type, :description)
ON CONFLICT (setting_key) DO NOTHING
"""
),
{
"key": key,
"value": value,
"value_type": value_type,
"description": description,
},
)
def downgrade() -> None:
op.execute(sa.text("DELETE FROM sms.sms_template WHERE id = :id").bindparams(id=TEMPLATE_ID))
op.execute(
"""
DELETE FROM sms.sms_setting
WHERE setting_key IN (
'provider.idgtl.default_sender_name',
'provider.idgtl.connect_timeout_ms',
'provider.idgtl.request_timeout_ms',
'provider.idgtl.callback_enabled',
'worker.poll_interval_ms',
'worker.lease_seconds'
)
"""
)
@@ -0,0 +1,314 @@
openapi: 3.1.0
info:
title: HAN SMS Service
version: 1.0.0
description: Durable internal SMS ordering and i-Digital delivery callbacks.
servers:
- url: http://sms-service:8080
paths:
/internal/sms/v1/send:
post:
operationId: orderSms
security:
- serviceBearer: []
parameters:
- $ref: "#/components/parameters/RequestId"
- $ref: "#/components/parameters/Traceparent"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/SendRequest"
responses:
"202":
description: New order durably committed; provider has not necessarily been called.
content:
application/json:
schema:
$ref: "#/components/schemas/SendResponse"
"200":
description: Idempotent replay of an existing order.
content:
application/json:
schema:
$ref: "#/components/schemas/SendResponse"
"401":
$ref: "#/components/responses/Unauthorized"
"409":
$ref: "#/components/responses/IdempotencyConflict"
"422":
$ref: "#/components/responses/InvalidRequest"
"429":
$ref: "#/components/responses/RateLimited"
"503":
$ref: "#/components/responses/Unavailable"
/internal/sms/v1/messages/{sms_message_id}:
get:
operationId: readSmsOrder
security:
- serviceBearer: []
parameters:
- name: sms_message_id
in: path
required: true
schema:
type: string
format: uuid
- $ref: "#/components/parameters/RequestId"
responses:
"200":
description: Redacted message diagnostics; never contains OTP, body, or full phone.
content:
application/json:
schema:
$ref: "#/components/schemas/Message"
"401":
$ref: "#/components/responses/Unauthorized"
"404":
$ref: "#/components/responses/NotFound"
/callbacks/idgtl/sms:
post:
operationId: acceptIdgtlCallback
security:
- callbackBasic: []
requestBody:
required: true
content:
application/json:
schema:
type: array
minItems: 1
maxItems: 1000
items:
$ref: "#/components/schemas/IdgtlCallbackItem"
responses:
"204":
description: Valid callback items committed; invalid items were safely ignored.
"401":
$ref: "#/components/responses/Unauthorized"
"422":
$ref: "#/components/responses/InvalidRequest"
webhooks:
idgtlDeliveryStatus:
post:
summary: The same payload accepted at /callbacks/idgtl/sms.
security:
- callbackBasic: []
requestBody:
required: true
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/IdgtlCallbackItem"
responses:
"204":
description: Callback committed.
components:
securitySchemes:
serviceBearer:
type: http
scheme: bearer
bearerFormat: opaque-service-token
callbackBasic:
type: http
scheme: basic
parameters:
RequestId:
name: X-Request-ID
in: header
required: false
schema:
type: string
maxLength: 128
Traceparent:
name: traceparent
in: header
required: false
schema:
type: string
pattern: "^[\\da-f]{2}-[\\da-f]{32}-[\\da-f]{16}-[\\da-f]{2}$"
schemas:
SendRequest:
type: object
additionalProperties: false
required:
- idempotency_key
- template_code
- locale
- phone_e164
- substitutions
- customer_ref
- message_ttl_sec
properties:
idempotency_key:
type: string
minLength: 8
maxLength: 192
template_code:
const: auth_otp
locale:
const: ru
phone_e164:
type: string
pattern: "^\\+[1-9]\\d{7,14}$"
substitutions:
type: object
additionalProperties: false
required: [code, ttl_min]
properties:
code:
type: string
pattern: "^\\d{4,10}$"
ttl_min:
oneOf:
- type: string
pattern: "^\\d{1,3}$"
- type: integer
minimum: 1
maximum: 1440
customer_ref:
type: string
minLength: 1
maxLength: 128
message_ttl_sec:
type: integer
minimum: 60
maximum: 86400
SendResponse:
type: object
additionalProperties: false
required: [sms_message_id, ordered_at]
properties:
sms_message_id:
type: string
format: uuid
ordered_at:
type: string
format: date-time
Message:
type: object
additionalProperties: false
description: Deliberately excludes phone_e164, body_rendered, and substitutions.
required:
- sms_message_id
- ordered_at
- updated_at
- requester_service
- process
- channel
- provider
- phone_masked
- template_code
- send_status
- delivery_status
- attempt_count
properties:
sms_message_id: {type: string, format: uuid}
ordered_at: {type: string, format: date-time}
updated_at: {type: string, format: date-time}
requester_service: {const: keycloak}
process: {const: auth_otp}
channel: {const: SMS}
provider: {const: idgtl}
phone_masked: {type: string}
template_code: {const: auth_otp}
customer_ref: {type: [string, "null"]}
send_status:
enum: [pending, accepted, rejected, failed, uncertain, skipped]
delivery_status:
enum: [unknown, sent, delivered, undelivered, unsent]
provider_message_id: {type: [string, "null"]}
accepted_at: {type: [string, "null"], format: date-time}
sent_at: {type: [string, "null"], format: date-time}
delivered_at: {type: [string, "null"], format: date-time}
attempt_count: {type: integer, minimum: 0}
provider_error_code: {type: [string, "null"]}
IdgtlCallbackItem:
type: object
required:
- channelType
- messageUuid
- externalMessageId
- callbackEvent
- status
- statusTime
properties:
channelType:
const: SMS
messageUuid:
type: string
externalMessageId:
type: string
callbackEvent:
type: string
status:
enum: [sent, delivered, undelivered, unsent]
statusTime:
type: string
format: date-time
errorCode:
type: [string, "null"]
parts:
type: [integer, "null"]
minimum: 0
price:
type: [number, "null"]
minimum: 0
currency:
type: [string, "null"]
minLength: 3
maxLength: 3
Error:
type: object
additionalProperties: false
required: [error]
properties:
error:
type: object
additionalProperties: false
required: [code, message, request_id, details]
properties:
code: {type: string}
message: {type: string}
request_id: {type: string}
details:
oneOf:
- type: object
- type: array
responses:
Unauthorized:
description: Missing or invalid credentials.
content:
application/json:
schema: {$ref: "#/components/schemas/Error"}
IdempotencyConflict:
description: The key was already used with another meaningful payload.
content:
application/json:
schema: {$ref: "#/components/schemas/Error"}
InvalidRequest:
description: Strict request or callback validation failed.
content:
application/json:
schema: {$ref: "#/components/schemas/Error"}
RateLimited:
description: Caller and destination rate limit exceeded.
headers:
Retry-After:
schema: {type: integer}
content:
application/json:
schema: {$ref: "#/components/schemas/Error"}
Unavailable:
description: The order could not be durably committed.
content:
application/json:
schema: {$ref: "#/components/schemas/Error"}
NotFound:
description: Message was not found in the caller scope.
content:
application/json:
schema: {$ref: "#/components/schemas/Error"}
@@ -0,0 +1,65 @@
[project]
name = "han-sms-service"
version = "0.1.0"
description = "HAN Chat durable SMS delivery service"
requires-python = ">=3.12"
dependencies = [
"alembic>=1.16,<2",
"asyncpg>=0.30,<1",
"fastapi>=0.116,<1",
"httpx>=0.28,<1",
"opentelemetry-api>=1.44,<2",
"opentelemetry-exporter-otlp-proto-grpc>=1.44,<2",
"opentelemetry-instrumentation-fastapi>=0.65b0,<1",
"opentelemetry-instrumentation-httpx>=0.65b0,<1",
"opentelemetry-instrumentation-sqlalchemy>=0.65b0,<1",
"opentelemetry-sdk>=1.44,<2",
"phonenumbers>=9,<10",
"prometheus-client>=0.22,<1",
"pydantic-settings>=2.10,<3",
"sqlalchemy[asyncio]>=2.0.41,<3",
"structlog>=25,<26",
"uvicorn[standard]>=0.35,<1",
]
[project.optional-dependencies]
dev = [
"aiosqlite>=0.21,<1",
"mypy>=1.16,<2",
"pytest>=8.4,<9",
"pytest-asyncio>=1.0,<2",
"pyyaml>=6,<7",
"ruff>=0.12,<1",
]
[project.scripts]
han-sms-api = "app.main:run"
han-sms-worker = "app.worker:run"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["app"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
[tool.ruff]
target-version = "py312"
line-length = 100
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "ASYNC", "S"]
ignore = ["S101"]
[tool.mypy]
python_version = "3.12"
check_untyped_defs = true
warn_redundant_casts = true
warn_unused_ignores = true
ignore_missing_imports = true
plugins = ["pydantic.mypy", "sqlalchemy.ext.mypy.plugin"]
exclude = ["migrations/"]
@@ -0,0 +1,25 @@
from pathlib import Path
import yaml
def test_static_contract_is_openapi_31_and_redacted() -> None:
contract = yaml.safe_load(
(Path(__file__).parents[2] / "openapi.yaml").read_text(encoding="utf-8")
)
assert contract["openapi"] == "3.1.0"
paths = contract["paths"]
assert "/internal/sms/v1/send" in paths
assert "/internal/sms/v1/messages/{sms_message_id}" in paths
assert "/callbacks/idgtl/sms" in paths
message_fields = contract["components"]["schemas"]["Message"]["properties"]
assert {"phone_e164", "body_rendered", "substitutions"}.isdisjoint(message_fields)
assert "idgtlDeliveryStatus" in contract["webhooks"]
def test_send_contract_distinguishes_new_and_replayed_order() -> None:
contract = yaml.safe_load(
(Path(__file__).parents[2] / "openapi.yaml").read_text(encoding="utf-8")
)
responses = contract["paths"]["/internal/sms/v1/send"]["post"]["responses"]
assert {"200", "202", "401", "409", "422", "429", "503"} <= responses.keys()
@@ -0,0 +1,35 @@
import base64
from types import SimpleNamespace
import pytest
from pydantic import SecretStr
from app.domain import DomainError
from app.main import basic_auth, bearer_auth
def request_with(authorization: str):
settings = SimpleNamespace(
service_token=SecretStr("s" * 43),
callback_username=SecretStr("callback-user"),
callback_password=SecretStr("callback-password"),
)
return SimpleNamespace(
headers={"Authorization": authorization},
app=SimpleNamespace(state=SimpleNamespace(settings=settings)),
)
@pytest.mark.asyncio
async def test_internal_api_requires_exact_bearer_token() -> None:
await bearer_auth(request_with(f"Bearer {'s' * 43}"))
with pytest.raises(DomainError) as error:
await bearer_auth(request_with("Bearer wrong"))
assert error.value.code == "unauthorized"
def test_callback_requires_exact_basic_credentials() -> None:
encoded = base64.b64encode(b"callback-user:callback-password").decode()
basic_auth(request_with(f"Basic {encoded}"))
with pytest.raises(DomainError):
basic_auth(request_with("Basic invalid"))
@@ -0,0 +1,85 @@
import pytest
from app.db import DeliveryStatus
from app.domain import (
DomainError,
delivery_transition,
normalize_phone,
render_template,
request_fingerprint,
sms_parts,
)
def test_phone_is_canonical_and_masked() -> None:
e164, digits, masked = normalize_phone("+79001234567")
assert e164 == "+79001234567"
assert digits == "79001234567"
assert masked == "+7******4567"
@pytest.mark.parametrize("phone", ["79001234567", "+012345678", "+7900", "+7999999999999999"])
def test_invalid_phone_is_rejected(phone: str) -> None:
with pytest.raises(DomainError) as error:
normalize_phone(phone)
assert error.value.code == "sms_request_invalid"
def test_strict_template_render() -> None:
result = render_template(
"Код входа: {code}. Действителен {ttl_min} мин.",
["code", "ttl_min"],
{"code": "482193", "ttl_min": 1},
1,
)
assert result == "Код входа: 482193. Действителен 1 мин."
@pytest.mark.parametrize(
"substitutions",
[
{"code": "482193"},
{"code": "482193", "ttl_min": 1, "extra": "forbidden"},
],
)
def test_template_rejects_placeholder_mismatch(substitutions) -> None:
with pytest.raises(DomainError):
render_template(
"Код: {code}; TTL: {ttl_min}",
["code", "ttl_min"],
substitutions,
1,
)
def test_template_rejects_format_expressions() -> None:
with pytest.raises(DomainError):
render_template("{code!r}", ["code"], {"code": "123456"}, 1)
def test_sms_parts_supports_gsm_and_unicode() -> None:
assert sms_parts("A" * 160) == 1
assert sms_parts("A" * 161) == 2
assert sms_parts("Я" * 70) == 1
assert sms_parts("Я" * 71) == 2
def test_fingerprint_is_canonical() -> None:
first = request_fingerprint({"b": 2, "a": {"y": 2, "x": 1}})
second = request_fingerprint({"a": {"x": 1, "y": 2}, "b": 2})
assert first == second
@pytest.mark.parametrize(
("current", "incoming", "expected"),
[
(DeliveryStatus.UNKNOWN, "sent", DeliveryStatus.SENT),
(DeliveryStatus.SENT, "delivered", DeliveryStatus.DELIVERED),
(DeliveryStatus.DELIVERED, "sent", DeliveryStatus.DELIVERED),
(DeliveryStatus.UNDELIVERED, "sent", DeliveryStatus.UNDELIVERED),
(DeliveryStatus.DELIVERED, "unsent", DeliveryStatus.DELIVERED),
(DeliveryStatus.UNKNOWN, "bogus", None),
],
)
def test_delivery_status_is_monotonic(current, incoming, expected) -> None:
assert delivery_transition(current, incoming) == expected
@@ -0,0 +1,31 @@
from app.logging_security import REDACTED, redact_event, sanitize_text
def test_redacts_sensitive_fields_recursively() -> None:
event = {
"api_key": "provider-key",
"nested": {
"callback_url": "https://user:password@example.test/callback",
"status": "accepted",
},
}
redacted = redact_event(None, "info", event)
assert redacted["api_key"] == REDACTED
assert redacted["nested"]["callback_url"] == REDACTED
assert redacted["nested"]["status"] == "accepted"
def test_redacts_credentials_embedded_in_text() -> None:
value = (
"POST https://callback-user:callback-password@example.test/cb"
"?api_key=query-secret Authorization=Basic header-secret"
)
redacted = sanitize_text(value)
assert "callback-password" not in redacted
assert "query-secret" not in redacted
assert "header-secret" not in redacted
assert redacted.count(REDACTED) == 3
@@ -0,0 +1,90 @@
import uuid
import httpx
import pytest
from app.db import SendStatus
from app.provider import IdgtlConfig, callback_url_with_credentials, classify_response
def response(status: int, payload=None) -> httpx.Response:
request = httpx.Request("POST", "https://direct.example/api/v1/message")
if payload is None:
return httpx.Response(status, request=request)
return httpx.Response(status, json=payload, request=request)
@pytest.mark.parametrize("status", [401, 402, 403, 422])
def test_explicit_business_rejections_are_not_retried(status: int) -> None:
result = classify_response(response(status), "message-id")
assert result.send_status == SendStatus.REJECTED
assert result.retry_safe is False
@pytest.mark.parametrize("status", [500, 502, 503, 504])
def test_ambiguous_http_results_are_uncertain(status: int) -> None:
result = classify_response(response(status), "message-id")
assert result.send_status == SendStatus.UNCERTAIN
assert result.retry_safe is False
def test_exact_success_contract() -> None:
message_uuid = str(uuid.uuid4())
result = classify_response(
response(
200,
{
"errors": False,
"response": [
{
"code": 201,
"messageUuid": message_uuid,
"externalMessageId": "message-id",
}
],
},
),
"message-id",
)
assert result.send_status == SendStatus.ACCEPTED
assert result.message_uuid == message_uuid
@pytest.mark.parametrize(
"payload",
[
{"errors": True, "response": []},
{"errors": False, "response": []},
{"errors": False, "response": [{"code": 200}]},
{
"errors": False,
"response": [
{
"code": 201,
"messageUuid": str(uuid.uuid4()),
"externalMessageId": "wrong",
}
],
},
],
)
def test_malformed_200_is_rejected_contract_violation(payload) -> None:
result = classify_response(response(200, payload), "message-id")
assert result.send_status == SendStatus.REJECTED
assert result.contract_violation is True
def test_callback_credentials_are_url_encoded() -> None:
config = IdgtlConfig(
base_url="https://direct.example",
api_key="api-key",
callback_url="https://tohin.ru/callbacks/idgtl/sms",
callback_username="user@example",
callback_password="p:a/ss", # noqa: S106 - synthetic URL-encoding fixture
connect_timeout_ms=3000,
request_timeout_ms=70000,
callback_enabled=True,
)
assert callback_url_with_credentials(config) == (
"https://user%40example:p%3Aa%2Fss@tohin.ru/callbacks/idgtl/sms"
)
@@ -0,0 +1,53 @@
import pytest
from pydantic import ValidationError
from app.domain import DomainError
from app.schemas import CallbackItem, SendRequest
from app.service import validate_otp_request
def valid_send(**overrides) -> SendRequest:
payload = {
"idempotency_key": "keycloak:challenge:01JABCDEF",
"template_code": "auth_otp",
"locale": "ru",
"phone_e164": "+79001234567",
"substitutions": {"code": "482193", "ttl_min": "1"},
"customer_ref": "01JABCDEF",
"message_ttl_sec": 60,
}
payload.update(overrides)
return SendRequest.model_validate(payload)
def test_send_request_is_strict() -> None:
with pytest.raises(ValidationError):
valid_send(extra="forbidden")
@pytest.mark.parametrize(
("substitutions", "ttl"),
[
({"code": "12ab", "ttl_min": "1"}, 60),
({"code": "123456", "ttl_min": "2"}, 60),
({"code": "123456", "ttl_min": "1"}, 61),
],
)
def test_otp_substitutions_match_ttl(substitutions, ttl) -> None:
with pytest.raises(DomainError):
validate_otp_request(valid_send(substitutions=substitutions, message_ttl_sec=ttl))
def test_callback_accepts_provider_camel_case() -> None:
item = CallbackItem.model_validate(
{
"channelType": "SMS",
"messageUuid": "provider-id",
"externalMessageId": "internal-id",
"callbackEvent": "delivered",
"status": "delivered",
"statusTime": "2026-07-22T12:00:00Z",
}
)
assert item.channel_type == "SMS"
assert item.status_time.tzinfo is not None
@@ -0,0 +1,21 @@
from opentelemetry.sdk.trace import TracerProvider
from app import telemetry
def test_telemetry_is_fail_open_without_endpoint(monkeypatch) -> None:
monkeypatch.delenv("OTEL_EXPORTER_OTLP_ENDPOINT", raising=False)
monkeypatch.setattr(telemetry, "_runtime", None)
assert telemetry.init_telemetry("sms-test") is None
def test_structlog_processor_adds_active_trace_context() -> None:
tracer = TracerProvider().get_tracer("test")
with tracer.start_as_current_span("sms.operation"):
result = telemetry.add_trace_context(None, "info", {"event": "sms.safe"})
assert result["event"] == "sms.safe"
assert len(result["trace_id"]) == 32
assert len(result["span_id"]) == 16