Реализована интеграция с СМС провайдером

This commit is contained in:
mi
2026-07-23 11:49:15 +03:00
parent cc0163eb94
commit b1ed714d5b
89 changed files with 5934 additions and 202 deletions
@@ -0,0 +1 @@
"""HAN SMS service."""
+253
View File
@@ -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()
+154
View File
@@ -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
+322
View File
@@ -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
+308
View File
@@ -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()
+217
View File
@@ -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()