Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 29 additions & 5 deletions src/tac/server/fastapi_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,17 @@
from __future__ import annotations

import asyncio
from typing import TYPE_CHECKING, Any
from typing import Any

from tac.channels.base import BaseChannel
from tac.channels.messaging import MessagingChannel
from tac.channels.voice import VoiceChannel
from tac.channels.websocket_protocol import WebSocketDisconnectError
from tac.core.logging import get_logger
from tac.core.tac import TAC
from tac.models.voice import TwiMLRequest
from tac.server.config import TACServerConfig

if TYPE_CHECKING:
from tac.channels.messaging import MessagingChannel
from tac.channels.voice import VoiceChannel

try:
import uvicorn
from fastapi import Depends, FastAPI, Request, WebSocket, WebSocketDisconnect
Expand Down Expand Up @@ -112,6 +110,8 @@ def __init__(
self.voice_channel = voice_channel
self.messaging_channels: list[MessagingChannel] = messaging_channels or []

self._validate_channel_types()

if self.voice_channel is not None:
self._validate_voice_url_config()

Expand All @@ -124,6 +124,30 @@ def __init__(
self.app: FastAPI = app if app is not None else FastAPI(title="TAC Server")
self._register_routes(self.app)

def _validate_channel_types(self) -> None:
"""Fail fast at server construction if a channel is the wrong type.

A common mistake is passing ``None`` (e.g. a connector channel that
wasn't configured) or an otherwise wrong object into
``messaging_channels`` / ``voice_channel``. Without this check that
surfaces much later as an opaque ``AttributeError`` (e.g. ``'NoneType'
object has no attribute 'get_channel_name'``) deep in webhook dispatch,
far from the actual misconfiguration.
Comment on lines +132 to +135
"""
if self.voice_channel is not None and not isinstance(self.voice_channel, VoiceChannel):
raise TypeError(
"voice_channel must be a VoiceChannel or None, got "
f"{type(self.voice_channel).__name__}."
)
for channel in self.messaging_channels:
if not isinstance(channel, MessagingChannel):
raise TypeError(
"messaging_channels must contain MessagingChannel instances "
"(SMSChannel, RCSChannel, WhatsAppChannel, ChatChannel), got "
f"{type(channel).__name__}. A None here usually means a channel "
"that wasn't configured was passed through — filter those out."
)
Comment on lines +142 to +149

def _validate_voice_url_config(self) -> None:
"""Fail fast at server construction if the voice channel can't build a
WebSocket URL.
Expand Down
51 changes: 51 additions & 0 deletions tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,57 @@ def test_passes_without_voice_channel(self) -> None:
TACFastAPIServer(tac=tac) # no raise


class TestChannelTypeValidationAtStartup:
"""TACFastAPIServer fails fast at construction if a channel is the wrong
type — e.g. a None (an unconfigured connector channel) slipping into
messaging_channels — instead of an opaque AttributeError during dispatch."""

def test_none_in_messaging_channels_raises(self) -> None:
from tac.channels import SMSChannel
from tac.server import TACFastAPIServer

tac = TAC(get_test_config())
with pytest.raises(TypeError, match="MessagingChannel"):
TACFastAPIServer(tac=tac, messaging_channels=[SMSChannel(tac), None])

def test_wrong_type_in_messaging_channels_raises(self) -> None:
from tac.server import TACFastAPIServer

tac = TAC(get_test_config())
with pytest.raises(TypeError, match="MessagingChannel"):
TACFastAPIServer(tac=tac, messaging_channels=["not a channel"])

def test_voice_channel_in_messaging_channels_raises(self) -> None:
"""A VoiceChannel is not a MessagingChannel — passing it in the
messaging list is a mistake and is rejected."""
from tac.channels.voice import VoiceChannel
from tac.server import TACFastAPIServer

tac = TAC(get_test_config())
with pytest.raises(TypeError, match="MessagingChannel"):
TACFastAPIServer(tac=tac, messaging_channels=[VoiceChannel(tac)])

def test_wrong_type_voice_channel_raises(self) -> None:
from tac.channels import SMSChannel
from tac.server import TACFastAPIServer

tac = TAC(get_test_config())
with pytest.raises(TypeError, match="VoiceChannel"):
TACFastAPIServer(tac=tac, voice_channel=SMSChannel(tac))

def test_valid_channels_pass(self) -> None:
from tac.channels import ChatChannel, SMSChannel
from tac.channels.voice import VoiceChannel
from tac.server import TACFastAPIServer

tac = TAC(get_test_config())
TACFastAPIServer(
tac=tac,
voice_channel=VoiceChannel(tac),
messaging_channels=[SMSChannel(tac), ChatChannel(tac)],
) # no raise


class TestWebSocketDisconnectError:
"""Test WebSocketDisconnectError."""

Expand Down
Loading