Files
han-app/VM1_app/codebase/backend/sms-service/app/provider.py
T

162 lines
5.6 KiB
Python

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))