Skip to content

Commit a56744d

Browse files
committed
fix: AsyncKwtSMS error handling, 200-number limit, logging, docstrings
1 parent a77b399 commit a56744d

3 files changed

Lines changed: 61 additions & 9 deletions

File tree

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,6 @@ dev = [
4747
"pytest-asyncio>=0.24.0",
4848
"pytest>=7.0",
4949
]
50+
51+
[tool.pytest.ini_options]
52+
asyncio_mode = "strict"

src/kwtsms/_async.py

Lines changed: 51 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,14 @@ async def main():
1616

1717
import json
1818
import os
19+
from datetime import datetime, timezone
1920
from typing import List, Optional, Union
2021

2122
from kwtsms._core import (
2223
BASE_URL,
2324
_enrich_error,
2425
_load_env_file,
26+
_write_log,
2527
clean_message,
2628
validate_phone_input,
2729
)
@@ -40,14 +42,34 @@ async def _async_request(endpoint: str, payload: dict, log_file: str = "") -> di
4042
"aiohttp is required for async usage. Install with: pip install kwtsms[async]"
4143
)
4244
url = BASE_URL + endpoint + "/"
43-
async with aiohttp.ClientSession() as session:
44-
async with session.post(url, json=payload,
45-
timeout=aiohttp.ClientTimeout(total=15)) as resp:
46-
text = await resp.text()
47-
try:
48-
return json.loads(text)
49-
except json.JSONDecodeError as exc:
50-
raise RuntimeError(f"Invalid JSON response: {exc}") from exc
45+
safe_payload = {k: ("***" if k == "password" else v) for k, v in payload.items()}
46+
log_entry = {
47+
"ts": datetime.now(timezone.utc).isoformat(),
48+
"endpoint": endpoint,
49+
"request": safe_payload,
50+
"response": None,
51+
"ok": False,
52+
"error": None,
53+
}
54+
try:
55+
async with aiohttp.ClientSession() as session:
56+
async with session.post(url, json=payload,
57+
timeout=aiohttp.ClientTimeout(total=15)) as resp:
58+
text = await resp.text()
59+
try:
60+
data = json.loads(text)
61+
except json.JSONDecodeError as exc:
62+
log_entry["error"] = f"Invalid JSON response: {exc}"
63+
_write_log(log_file, log_entry)
64+
raise RuntimeError(f"Invalid JSON response: {exc}") from exc
65+
log_entry["response"] = data
66+
log_entry["ok"] = data.get("result") == "OK"
67+
_write_log(log_file, log_entry)
68+
return data
69+
except aiohttp.ClientError as exc:
70+
log_entry["error"] = f"Network error: {exc}"
71+
_write_log(log_file, log_entry)
72+
raise RuntimeError(f"Network error: {exc}") from exc
5173

5274

5375
class AsyncKwtSMS:
@@ -64,6 +86,16 @@ class AsyncKwtSMS:
6486

6587
def __init__(self, username: str, password: str, sender_id: str = "KWT-SMS",
6688
test_mode: bool = False, log_file: str = "kwtsms.log"):
89+
"""
90+
Create an AsyncKwtSMS client.
91+
92+
Args:
93+
username: kwtSMS API username
94+
password: kwtSMS API password
95+
sender_id: default Sender ID shown on recipient's phone
96+
test_mode: if True, sends are billed as test (no credits consumed)
97+
log_file: path to JSONL request log file, or "" to disable logging
98+
"""
6799
if not username or not password:
68100
raise ValueError("username and password are required")
69101
self.username = username
@@ -107,6 +139,7 @@ def _creds(self) -> dict:
107139

108140
@property
109141
def purchased(self) -> Optional[float]:
142+
"""Total credits purchased. None before the first successful verify() call."""
110143
return self._cached_purchased
111144

112145
async def verify(self) -> tuple:
@@ -125,7 +158,7 @@ async def verify(self) -> tuple:
125158
return False, None, str(exc)
126159

127160
async def balance(self) -> Optional[float]:
128-
"""Get current balance. Returns None on error."""
161+
"""Get current balance. Returns None on error (or last cached value if available)."""
129162
ok, bal, _ = await self.verify()
130163
return bal if ok else self._cached_balance
131164

@@ -134,6 +167,8 @@ async def send(self, mobile: Union[str, List[str]], message: str,
134167
"""
135168
Send SMS to one or more numbers. Same contract as KwtSMS.send().
136169
170+
Note: Maximum 200 numbers per call. For larger lists, split into batches.
171+
137172
Returns OK or ERROR dict; never raises.
138173
"""
139174
effective_sender = sender or self.sender_id
@@ -154,6 +189,13 @@ async def send(self, mobile: Union[str, List[str]], message: str,
154189
return _enrich_error({"result": "ERROR", "code": "ERR_INVALID_INPUT",
155190
"description": description, "invalid": invalid})
156191

192+
if len(valid_numbers) > 200:
193+
return _enrich_error({
194+
"result": "ERROR",
195+
"code": "ERR007",
196+
"description": f"Too many numbers ({len(valid_numbers)}). Maximum 200 per call. For larger lists, call send() in batches.",
197+
})
198+
157199
cleaned = clean_message(message)
158200
if not cleaned:
159201
return _enrich_error({"result": "ERROR", "code": "ERR009",

tests/test_async.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,10 @@ async def test_verify_network_error_returns_false(self):
5353
ok, balance, error = await _async_client().verify()
5454
assert ok is False
5555
assert "Timeout" in error
56+
57+
@pytest.mark.asyncio
58+
async def test_send_too_many_numbers_returns_err007(self):
59+
numbers = [f"9659{i:07d}" for i in range(201)]
60+
result = await _async_client().send(numbers, "Hello")
61+
assert result["result"] == "ERROR"
62+
assert result["code"] == "ERR007"

0 commit comments

Comments
 (0)