Skip to content

Commit d97ada4

Browse files
committed
test: add tests for non-2xx HTTP status handling (fixes #3091)
Verify that StreamableHTTPTransport._handle_post_request properly handles 401/403/5xx by sending JSONRPCError to the caller instead of letting the request timeout. Tests: - 401 → JSONRPCError with INTERNAL_ERROR code - 403 → JSONRPCError with INTERNAL_ERROR code - 500 → JSONRPCError with INTERNAL_ERROR code - JSON error body → parsed and used directly Closes #3091
1 parent 1216c53 commit d97ada4

1 file changed

Lines changed: 146 additions & 0 deletions

File tree

tests/client/test_non2xx_status.py

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
"""Tests for non-2xx HTTP status handling in StreamableHTTPTransport.
2+
3+
Verifies that when the server returns 401/403/5xx, the caller receives
4+
a proper JSONRPCError (not a timeout).
5+
6+
Closes #3091
7+
"""
8+
import json
9+
import pytest
10+
import httpx
11+
from unittest.mock import AsyncMock, MagicMock, patch
12+
13+
from mcp.client.streamable_http import StreamableHTTPTransport, RequestContext
14+
from mcp_types import JSONRPCRequest, JSONRPCError, ErrorData, SessionMessage
15+
16+
17+
class TestNon2xxStatusHandling:
18+
"""Test that non-2xx status codes produce proper error responses."""
19+
20+
def _make_request_context(self, request_id: str = "test-123") -> MagicMock:
21+
"""Create a mock RequestContext."""
22+
ctx = MagicMock(spec=RequestContext)
23+
ctx.session_message = MagicMock()
24+
ctx.session_message.message = MagicMock(spec=JSONRPCRequest)
25+
ctx.session_message.message.id = request_id
26+
ctx.read_stream_writer = AsyncMock()
27+
ctx.client = AsyncMock()
28+
ctx.metadata = None
29+
return ctx
30+
31+
@pytest.mark.anyio
32+
async def test_401_produces_error_response(self):
33+
"""401 Unauthorized should produce a JSONRPCError with the request's ID."""
34+
# This test verifies the fix for #3091
35+
# When server returns 401, caller should get a proper error, not a timeout
36+
transport = StreamableHTTPTransport.__new__(StreamableHTTPTransport)
37+
transport.url = "http://test"
38+
transport.session_id = None
39+
40+
ctx = self._make_request_context()
41+
42+
# Mock the response to return 401
43+
mock_response = MagicMock()
44+
mock_response.status_code = 401
45+
mock_response.headers = {"content-type": "text/plain"}
46+
mock_response.aread = AsyncMock(return_value=b"Unauthorized")
47+
48+
# Mock the context manager for stream()
49+
mock_stream_ctx = AsyncMock()
50+
mock_stream_ctx.__aenter__ = AsyncMock(return_value=mock_response)
51+
mock_stream_ctx.__aexit__ = AsyncMock(return_value=False)
52+
ctx.client.stream.return_value = mock_stream_ctx
53+
54+
# Call _handle_post_request
55+
await transport._handle_post_request(ctx)
56+
57+
# Verify that an error was sent to the read_stream_writer
58+
ctx.read_stream_writer.send.assert_called_once()
59+
sent_message = ctx.read_stream_writer.send.call_args[0][0]
60+
assert isinstance(sent_message, SessionMessage)
61+
assert isinstance(sent_message.message, JSONRPCError)
62+
assert sent_message.message.id == "test-123"
63+
assert sent_message.message.error.code == -32603 # INTERNAL_ERROR
64+
65+
@pytest.mark.anyio
66+
async def test_403_produces_error_response(self):
67+
"""403 Forbidden should produce a JSONRPCError with the request's ID."""
68+
transport = StreamableHTTPTransport.__new__(StreamableHTTPTransport)
69+
transport.url = "http://test"
70+
transport.session_id = None
71+
72+
ctx = self._make_request_context()
73+
74+
mock_response = MagicMock()
75+
mock_response.status_code = 403
76+
mock_response.headers = {"content-type": "text/plain"}
77+
mock_response.aread = AsyncMock(return_value=b"Forbidden")
78+
79+
mock_stream_ctx = AsyncMock()
80+
mock_stream_ctx.__aenter__ = AsyncMock(return_value=mock_response)
81+
mock_stream_ctx.__aexit__ = AsyncMock(return_value=False)
82+
ctx.client.stream.return_value = mock_stream_ctx
83+
84+
await transport._handle_post_request(ctx)
85+
86+
ctx.read_stream_writer.send.assert_called_once()
87+
sent_message = ctx.read_stream_writer.send.call_args[0][0]
88+
assert isinstance(sent_message.message, JSONRPCError)
89+
assert sent_message.message.id == "test-123"
90+
91+
@pytest.mark.anyio
92+
async def test_500_produces_error_response(self):
93+
"""500 Internal Server Error should produce a JSONRPCError."""
94+
transport = StreamableHTTPTransport.__new__(StreamableHTTPTransport)
95+
transport.url = "http://test"
96+
transport.session_id = None
97+
98+
ctx = self._make_request_context()
99+
100+
mock_response = MagicMock()
101+
mock_response.status_code = 500
102+
mock_response.headers = {"content-type": "text/plain"}
103+
mock_response.aread = AsyncMock(return_value=b"Internal Server Error")
104+
105+
mock_stream_ctx = AsyncMock()
106+
mock_stream_ctx.__aenter__ = AsyncMock(return_value=mock_response)
107+
mock_stream_ctx.__aexit__ = AsyncMock(return_value=False)
108+
ctx.client.stream.return_value = mock_stream_ctx
109+
110+
await transport._handle_post_request(ctx)
111+
112+
ctx.read_stream_writer.send.assert_called_once()
113+
sent_message = ctx.read_stream_writer.send.call_args[0][0]
114+
assert isinstance(sent_message.message, JSONRPCError)
115+
116+
@pytest.mark.anyio
117+
async def test_json_error_body_is_parsed(self):
118+
"""When server returns JSON-RPC error body, it should be used directly."""
119+
transport = StreamableHTTPTransport.__new__(StreamableHTTPTransport)
120+
transport.url = "http://test"
121+
transport.session_id = None
122+
123+
ctx = self._make_request_context()
124+
125+
error_body = json.dumps({
126+
"jsonrpc": "2.0",
127+
"id": "test-123",
128+
"error": {"code": -32600, "message": "Invalid Request"}
129+
}).encode()
130+
131+
mock_response = MagicMock()
132+
mock_response.status_code = 400
133+
mock_response.headers = {"content-type": "application/json"}
134+
mock_response.aread = AsyncMock(return_value=error_body)
135+
136+
mock_stream_ctx = AsyncMock()
137+
mock_stream_ctx.__aenter__ = AsyncMock(return_value=mock_response)
138+
mock_stream_ctx.__aexit__ = AsyncMock(return_value=False)
139+
ctx.client.stream.return_value = mock_stream_ctx
140+
141+
await transport._handle_post_request(ctx)
142+
143+
ctx.read_stream_writer.send.assert_called_once()
144+
sent_message = ctx.read_stream_writer.send.call_args[0][0]
145+
assert isinstance(sent_message.message, JSONRPCError)
146+
assert sent_message.message.error.code == -32600

0 commit comments

Comments
 (0)