Skip to content

Commit 0f9f4ad

Browse files
committed
Add Turn client
1 parent 6dc09ab commit 0f9f4ad

5 files changed

Lines changed: 240 additions & 93 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,5 +101,7 @@ There is a [docker image](https://github.com/praekeltfoundation/mc-intent-classi
101101
| CELERY_BROKER_URL | RabbitMQ connection URL (e.g., amqp://user:pass@host:5672/) | Yes (for async processing) |
102102
| CELERY_RESULT_BACKEND | Result backend URL (e.g., rpc://) | No (defaults to rpc://) |
103103
| CELERY_TASK_ALWAYS_EAGER | Set to "true" for synchronous task execution (testing only) | No |
104+
| TURN_API_BASE_URL | Turn API base URL (e.g., https://whatsapp.turn.io) | Yes (for Turn integration) |
105+
| TURN_API_TOKEN | Turn API authentication token (Bearer token) | Yes (for Turn integration) |
104106

105107
**Note:** You need to run both the Flask app (webhook receiver) and Celery worker (task processor) containers for full functionality.

poetry.lock

Lines changed: 1 addition & 92 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ numpy = "2.2.5"
2020
torch = "2.6.0"
2121
sentence-transformers = "^3.1.1"
2222
celery = {extras = ["amqp"], version = "^5.4.0"}
23-
httpx = "^0.27.0"
23+
requests = "^2.32.0"
2424
pydantic = "^2.9.0"
2525

2626
[tool.poetry.group.dev.dependencies]

src/turn_client.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
"""Turn API client for updating message labels."""
2+
3+
import logging
4+
import os
5+
6+
import requests
7+
8+
logger = logging.getLogger(__name__)
9+
10+
11+
class TurnAPIError(Exception):
12+
"""Base exception for Turn API errors."""
13+
14+
15+
class TurnAPIClientError(TurnAPIError):
16+
"""Exception for 4xx client errors (won't retry)."""
17+
18+
19+
class TurnAPIServerError(TurnAPIError):
20+
"""Exception for 5xx server errors (can retry)."""
21+
22+
23+
class TurnAPIClient:
24+
"""Client for interacting with Turn API to update message labels."""
25+
26+
def __init__(self):
27+
"""
28+
Initialize Turn API client.
29+
30+
Raises:
31+
ValueError: If TURN_API_BASE_URL or TURN_API_TOKEN are not set
32+
"""
33+
self.base_url = os.environ.get("TURN_API_BASE_URL", "").rstrip("/")
34+
self.api_token = os.environ.get("TURN_API_TOKEN", "")
35+
36+
if not self.base_url:
37+
msg = "TURN_API_BASE_URL must be set"
38+
raise ValueError(msg)
39+
40+
if not self.api_token:
41+
msg = "TURN_API_TOKEN must be set"
42+
raise ValueError(msg)
43+
44+
self.session = requests.Session()
45+
self.session.headers.update(
46+
{
47+
"Authorization": f"Bearer {self.api_token}",
48+
"Accept": "application/vnd.v1+json",
49+
"Content-Type": "application/json",
50+
}
51+
)
52+
53+
def update_message_label(self, message_id: str, label: str) -> dict:
54+
"""
55+
Update a message label in Turn.
56+
57+
Args:
58+
message_id: Turn message ID
59+
label: Intent label to assign
60+
61+
Returns:
62+
dict: Response from Turn API
63+
64+
Raises:
65+
TurnAPIClientError: For 4xx errors (invalid request)
66+
TurnAPIServerError: For 5xx errors (server issues)
67+
TurnAPIError: For other errors
68+
"""
69+
endpoint = f"{self.base_url}/v1/messages/{message_id}/labels"
70+
71+
payload = {"labels": [label]}
72+
73+
try:
74+
logger.info(f"Updating Turn message {message_id} with label: {label}")
75+
76+
response = self.session.post(endpoint, json=payload)
77+
78+
# Check for errors
79+
if response.status_code >= 500:
80+
error_msg = f"Turn API server error (HTTP {response.status_code}): {response.text}"
81+
logger.error(error_msg)
82+
raise TurnAPIServerError(error_msg)
83+
84+
if response.status_code >= 400:
85+
error_msg = f"Turn API client error (HTTP {response.status_code}): {response.text}"
86+
logger.error(error_msg)
87+
raise TurnAPIClientError(error_msg)
88+
89+
# Success
90+
logger.info(f"Successfully updated message {message_id} label to {label}")
91+
return response.json() if response.text else {}
92+
93+
except requests.Timeout as e:
94+
error_msg = f"Turn API request timeout for message {message_id}: {e}"
95+
logger.error(error_msg)
96+
raise TurnAPIServerError(error_msg) from e
97+
98+
except requests.RequestException as e:
99+
error_msg = f"Turn API request error for message {message_id}: {e}"
100+
logger.error(error_msg)
101+
raise TurnAPIError(error_msg) from e

tests/test_turn_client.py

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
"""Tests for Turn API client."""
2+
3+
import pytest
4+
5+
from src.turn_client import (
6+
TurnAPIClient,
7+
TurnAPIClientError,
8+
TurnAPIError,
9+
TurnAPIServerError,
10+
)
11+
12+
13+
@pytest.fixture(autouse=True)
14+
def setup_turn_env(monkeypatch):
15+
"""Set up Turn API environment variables for all tests."""
16+
monkeypatch.setenv("TURN_API_BASE_URL", "https://api.turn.io")
17+
monkeypatch.setenv("TURN_API_TOKEN", "test-token")
18+
19+
20+
class TestTurnAPIClient:
21+
"""Tests for TurnAPIClient class."""
22+
23+
def test_client_initialization_with_env_vars(self):
24+
"""Test client initialization with environment variables."""
25+
client = TurnAPIClient()
26+
assert client.base_url == "https://api.turn.io"
27+
assert client.api_token == "test-token" # noqa: S105
28+
29+
def test_client_initialization_strips_trailing_slash(self, monkeypatch):
30+
"""Test that trailing slash is removed from base URL."""
31+
monkeypatch.setenv("TURN_API_BASE_URL", "https://api.turn.io/")
32+
monkeypatch.setenv("TURN_API_TOKEN", "test-token")
33+
34+
client = TurnAPIClient()
35+
assert client.base_url == "https://api.turn.io"
36+
37+
def test_client_initialization_missing_base_url(self, monkeypatch):
38+
"""Test that ValueError is raised if base URL is missing."""
39+
monkeypatch.delenv("TURN_API_BASE_URL", raising=False)
40+
monkeypatch.setenv("TURN_API_TOKEN", "test-token")
41+
42+
with pytest.raises(ValueError, match="TURN_API_BASE_URL must be set"):
43+
TurnAPIClient()
44+
45+
def test_client_initialization_missing_token(self, monkeypatch):
46+
"""Test that ValueError is raised if token is missing."""
47+
monkeypatch.setenv("TURN_API_BASE_URL", "https://api.turn.io")
48+
monkeypatch.delenv("TURN_API_TOKEN", raising=False)
49+
50+
with pytest.raises(ValueError, match="TURN_API_TOKEN must be set"):
51+
TurnAPIClient()
52+
53+
def test_update_message_label_success(self, mocker):
54+
"""Test successful label update."""
55+
client = TurnAPIClient()
56+
57+
# Mock successful response
58+
mock_response = mocker.Mock()
59+
mock_response.status_code = 200
60+
mock_response.text = '{"status": "success", "message_id": "msg-123"}'
61+
mock_response.json.return_value = {"status": "success", "message_id": "msg-123"}
62+
mock_post = mocker.patch.object(
63+
client.session, "post", return_value=mock_response
64+
)
65+
66+
result = client.update_message_label("msg-123", "compliment")
67+
68+
# Verify request
69+
mock_post.assert_called_once_with(
70+
"https://api.turn.io/v1/messages/msg-123/labels",
71+
json={"labels": ["compliment"]},
72+
)
73+
74+
# Verify response
75+
assert result == {"status": "success", "message_id": "msg-123"}
76+
77+
def test_update_message_label_client_error(self, mocker):
78+
"""Test 4xx client error handling."""
79+
client = TurnAPIClient()
80+
81+
# Mock 400 Bad Request
82+
mock_response = mocker.Mock()
83+
mock_response.status_code = 400
84+
mock_response.text = "Invalid label format"
85+
mocker.patch.object(client.session, "post", return_value=mock_response)
86+
87+
with pytest.raises(
88+
TurnAPIClientError, match="Turn API client error \\(HTTP 400\\)"
89+
):
90+
client.update_message_label("msg-123", "invalid-label")
91+
92+
def test_update_message_label_server_error(self, mocker):
93+
"""Test 5xx server error handling."""
94+
client = TurnAPIClient()
95+
96+
# Mock 500 Internal Server Error
97+
mock_response = mocker.Mock()
98+
mock_response.status_code = 500
99+
mock_response.text = "Internal server error"
100+
mocker.patch.object(client.session, "post", return_value=mock_response)
101+
102+
with pytest.raises(
103+
TurnAPIServerError, match="Turn API server error \\(HTTP 500\\)"
104+
):
105+
client.update_message_label("msg-123", "label")
106+
107+
def test_update_message_label_request_error(self, mocker):
108+
"""Test general request error handling."""
109+
import requests
110+
111+
client = TurnAPIClient()
112+
113+
# Mock request exception
114+
mocker.patch.object(
115+
client.session,
116+
"post",
117+
side_effect=requests.RequestException("Connection failed"),
118+
)
119+
120+
with pytest.raises(
121+
TurnAPIError, match="Turn API request error for message msg-123"
122+
):
123+
client.update_message_label("msg-123", "label")
124+
125+
def test_client_headers(self, monkeypatch):
126+
"""Test that client has correct headers."""
127+
monkeypatch.setenv("TURN_API_BASE_URL", "https://api.turn.io")
128+
monkeypatch.setenv("TURN_API_TOKEN", "test-token-123")
129+
130+
client = TurnAPIClient()
131+
132+
headers = client.session.headers
133+
assert headers["Authorization"] == "Bearer test-token-123"
134+
assert headers["Accept"] == "application/vnd.v1+json"
135+
assert headers["Content-Type"] == "application/json"

0 commit comments

Comments
 (0)