Skip to content

Commit 9a86a74

Browse files
fix: validate gateway server config block at load time (#3050)
Move the typed-validation fix from the example into the wrapper where the bug actually lives. load_gateway_config now validates the optional 'gateway:' server block field-by-field against the SDK GatewayConfig dataclass (single source of truth), so misspelled keys, wrong types and negative values are rejected with clear errors instead of silently falling back to defaults. Revert the example to use the typed SDK GatewayConfig instead of a local shadowing class that bypassed validation. Co-authored-by: Mervin Praison <MervinPraison@users.noreply.github.com>
1 parent 5f8d6e5 commit 9a86a74

3 files changed

Lines changed: 148 additions & 24 deletions

File tree

examples/python/gateway_example.py

Lines changed: 4 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -7,28 +7,8 @@
77

88
from praisonaiagents import Agent, GatewayConfig, SessionConfig
99

10-
# Configure the gateway
11-
from pydantic import BaseModel, Field
12-
13-
class ServerConfig(BaseModel):
14-
host: str = "0.0.0.0"
15-
port: int = 8000
16-
drain_timeout: int = 30
17-
reload_drain_timeout: int = 60
18-
max_connections: int = 1000
19-
heartbeat_interval: int = 30
20-
21-
class GatewayConfig:
22-
def __init__(self, host="0.0.0.0", port=8000, drain_timeout=30, reload_drain_timeout=60, max_connections=1000, heartbeat_interval=30, session_config=None):
23-
self.host = host
24-
self.port = port
25-
self.drain_timeout = drain_timeout
26-
self.reload_drain_timeout = reload_drain_timeout
27-
self.max_connections = max_connections
28-
self.heartbeat_interval = heartbeat_interval
29-
self.session_config = session_config or SessionConfig()
30-
self.ws_url = f"ws://{self.host}:{self.port}"
31-
10+
# Configure the gateway using the typed SDK GatewayConfig.
11+
# Invalid or misspelled fields are caught by the dataclass signature.
3212
gateway_config = GatewayConfig(
3313
host="127.0.0.1",
3414
port=8765,
@@ -37,7 +17,7 @@ def __init__(self, host="0.0.0.0", port=8000, drain_timeout=30, reload_drain_tim
3717
session_config=SessionConfig(
3818
timeout=3600,
3919
max_messages=500,
40-
)
20+
),
4121
)
4222

4323
# Create specialized agents
@@ -61,7 +41,7 @@ def __init__(self, host="0.0.0.0", port=8000, drain_timeout=30, reload_drain_tim
6141
print(f" WebSocket URL: {gateway_config.ws_url}")
6242
print(f" Max Connections: {gateway_config.max_connections}")
6343
print()
64-
44+
6545
# Test agent
6646
response = researcher.start("What are the key benefits of multi-agent systems?")
6747
print("Researcher Response:")

src/praisonai/praisonai/gateway/server.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,42 @@
3030
logger = logging.getLogger(__name__)
3131

3232

33+
def _closest_match(key: str, candidates) -> Optional[str]:
34+
"""Return the closest candidate name to ``key`` for typo suggestions."""
35+
import difflib
36+
37+
matches = difflib.get_close_matches(key, list(candidates), n=1, cutoff=0.6)
38+
return matches[0] if matches else None
39+
40+
41+
def _value_matches_field(value: Any, field_type: Any) -> bool:
42+
"""Best-effort check that ``value`` is compatible with a dataclass field type.
43+
44+
Handles the plain and string ("from __future__ import annotations") forms of
45+
the primitive/container types used by ``GatewayConfig``. bools are rejected
46+
where an int is expected. Unknown/complex types are treated as valid so we
47+
never reject something we cannot confidently validate.
48+
"""
49+
type_name = field_type if isinstance(field_type, str) else getattr(field_type, "__name__", str(field_type))
50+
type_name = type_name.replace("typing.", "")
51+
if type_name.startswith("Optional[") and type_name.endswith("]"):
52+
type_name = type_name[len("Optional["):-1]
53+
54+
if type_name.startswith("int"):
55+
return isinstance(value, int) and not isinstance(value, bool)
56+
if type_name.startswith("float"):
57+
return isinstance(value, (int, float)) and not isinstance(value, bool)
58+
if type_name.startswith("bool"):
59+
return isinstance(value, bool)
60+
if type_name.startswith("str"):
61+
return isinstance(value, str)
62+
if type_name.startswith("List") or type_name.startswith("list"):
63+
return isinstance(value, list)
64+
if type_name.startswith("Dict") or type_name.startswith("dict"):
65+
return isinstance(value, dict)
66+
return True
67+
68+
3369
@dataclass
3470
class GatewaySession:
3571
"""A gateway session tracking a conversation between client and agent."""
@@ -1358,6 +1394,42 @@ def load_gateway_config(cls, config_path: str) -> Dict[str, Any]:
13581394
"(use ${{ENV_VAR}} syntax for env vars)"
13591395
)
13601396

1397+
# Validate the optional 'gateway:' server block field-by-field,
1398+
# reusing the typed GatewayConfig dataclass as the single source of
1399+
# truth so misspelled or wrongly-typed server settings are caught at
1400+
# load time instead of being silently ignored at runtime (see #3050).
1401+
gw_block = raw.get("gateway")
1402+
if gw_block is not None:
1403+
if not isinstance(gw_block, dict):
1404+
errors.append("'gateway' must be a dictionary")
1405+
else:
1406+
import dataclasses
1407+
1408+
allowed_fields = {f.name: f for f in dataclasses.fields(GatewayConfig)}
1409+
# session_config is a nested dataclass configured elsewhere.
1410+
allowed_fields.pop("session_config", None)
1411+
for key, value in gw_block.items():
1412+
if key not in allowed_fields:
1413+
suggestion = _closest_match(key, allowed_fields.keys())
1414+
hint = f" (did you mean '{suggestion}'?)" if suggestion else ""
1415+
errors.append(
1416+
f"Unknown gateway setting '{key}'{hint}. "
1417+
f"Allowed: {', '.join(sorted(allowed_fields))}"
1418+
)
1419+
continue
1420+
if value is None:
1421+
continue
1422+
field_type = allowed_fields[key].type
1423+
if not _value_matches_field(value, field_type):
1424+
errors.append(
1425+
f"Gateway setting '{key}' has invalid type "
1426+
f"'{type(value).__name__}' (expected {field_type})"
1427+
)
1428+
elif isinstance(value, int) and not isinstance(value, bool) and value < 0:
1429+
errors.append(
1430+
f"Gateway setting '{key}' must not be negative (got {value})"
1431+
)
1432+
13611433
if errors:
13621434
msg = (
13631435
f"Gateway config validation failed ({config_path}):\n"

src/praisonai/tests/integration/test_websocket_integration.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,78 @@ async def test_gateway_config_valid(self, tmp_path):
134134
assert "channels" in cfg
135135
assert cfg["channels"]["telegram"]["token"] == "test123"
136136

137+
@pytest.mark.asyncio
138+
async def test_gateway_block_rejects_unknown_key(self, tmp_path):
139+
"""Misspelled gateway server settings are rejected with a suggestion."""
140+
try:
141+
from praisonai.gateway.server import WebSocketGateway
142+
except ImportError:
143+
pytest.skip("praisonai.gateway not available")
144+
145+
bad_config = tmp_path / "bad.yaml"
146+
bad_config.write_text(
147+
"agents:\n bot:\n instructions: hi\n"
148+
"channels:\n telegram:\n token: test123\n"
149+
"gateway:\n reconnect_timout: 60\n"
150+
)
151+
152+
with pytest.raises(ValueError, match="reconnect_timeout"):
153+
WebSocketGateway.load_gateway_config(str(bad_config))
154+
155+
@pytest.mark.asyncio
156+
async def test_gateway_block_rejects_wrong_type(self, tmp_path):
157+
"""A string where an int is expected is rejected at load time."""
158+
try:
159+
from praisonai.gateway.server import WebSocketGateway
160+
except ImportError:
161+
pytest.skip("praisonai.gateway not available")
162+
163+
bad_config = tmp_path / "bad.yaml"
164+
bad_config.write_text(
165+
"agents:\n bot:\n instructions: hi\n"
166+
"channels:\n telegram:\n token: test123\n"
167+
"gateway:\n port: notaport\n"
168+
)
169+
170+
with pytest.raises(ValueError, match="invalid type"):
171+
WebSocketGateway.load_gateway_config(str(bad_config))
172+
173+
@pytest.mark.asyncio
174+
async def test_gateway_block_rejects_negative(self, tmp_path):
175+
"""Negative numeric server settings are rejected."""
176+
try:
177+
from praisonai.gateway.server import WebSocketGateway
178+
except ImportError:
179+
pytest.skip("praisonai.gateway not available")
180+
181+
bad_config = tmp_path / "bad.yaml"
182+
bad_config.write_text(
183+
"agents:\n bot:\n instructions: hi\n"
184+
"channels:\n telegram:\n token: test123\n"
185+
"gateway:\n max_connections: -5\n"
186+
)
187+
188+
with pytest.raises(ValueError, match="must not be negative"):
189+
WebSocketGateway.load_gateway_config(str(bad_config))
190+
191+
@pytest.mark.asyncio
192+
async def test_gateway_block_valid(self, tmp_path):
193+
"""A valid gateway server block loads successfully."""
194+
try:
195+
from praisonai.gateway.server import WebSocketGateway
196+
except ImportError:
197+
pytest.skip("praisonai.gateway not available")
198+
199+
good_config = tmp_path / "good.yaml"
200+
good_config.write_text(
201+
"agents:\n bot:\n instructions: hi\n"
202+
"channels:\n telegram:\n token: test123\n"
203+
"gateway:\n host: 127.0.0.1\n port: 8765\n max_connections: 100\n"
204+
)
205+
206+
cfg = WebSocketGateway.load_gateway_config(str(good_config))
207+
assert cfg["gateway"]["port"] == 8765
208+
137209
@pytest.mark.asyncio
138210
async def test_websocket_connect_and_message(self):
139211
"""Test actual WebSocket connection to gateway server.

0 commit comments

Comments
 (0)