Skip to content

Commit ac5c57a

Browse files
authored
fix: pydantic error where status code could be None (#61)
### Summary Saw some ASGI errors from [plugins](https://platform-prod-stage-o11y.kb.us-central1.gcp.cloud.es.io:9243/app/r/s/stu2n) Pydantic error where dataclass is asking status code is a int but `e.status_code` is None
1 parent 52e6815 commit ac5c57a

File tree

5 files changed

+640
-309
lines changed

5 files changed

+640
-309
lines changed

test/api/test_api.py

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,3 +133,192 @@ def test_improper_function():
133133

134134
with pytest.raises(EtlApiException):
135135
TestClient(wrap_in_fastapi(func=test_fn, plugin_id="mock_plugin"))
136+
137+
138+
@pytest.mark.parametrize(
139+
"file_data", mock_file_data, ids=[type(fd).__name__ for fd in mock_file_data]
140+
)
141+
def test_exception_with_none_status_code(file_data):
142+
"""Test that exceptions with status_code=None are handled correctly."""
143+
from test.assets.exception_status_code import (
144+
function_raises_exception_with_none_status_code as test_fn,
145+
)
146+
147+
client = TestClient(wrap_in_fastapi(func=test_fn, plugin_id="mock_plugin"))
148+
149+
post_body = {"file_data": file_data.model_dump()}
150+
resp = client.post("/invoke", json=post_body)
151+
resp_content = resp.json()
152+
invoke_response = InvokeResponse.model_validate(resp_content)
153+
154+
# Should default to 500 when status_code is None
155+
assert invoke_response.status_code == 500
156+
assert "ExceptionWithNoneStatusCode" in invoke_response.status_code_text
157+
assert "Test exception with None status_code" in invoke_response.status_code_text
158+
159+
160+
@pytest.mark.parametrize(
161+
"file_data", mock_file_data, ids=[type(fd).__name__ for fd in mock_file_data]
162+
)
163+
def test_exception_with_valid_status_code(file_data):
164+
"""Test that exceptions with valid status_code are handled correctly."""
165+
from test.assets.exception_status_code import (
166+
function_raises_exception_with_valid_status_code as test_fn,
167+
)
168+
169+
client = TestClient(wrap_in_fastapi(func=test_fn, plugin_id="mock_plugin"))
170+
171+
post_body = {"file_data": file_data.model_dump()}
172+
resp = client.post("/invoke", json=post_body)
173+
resp_content = resp.json()
174+
invoke_response = InvokeResponse.model_validate(resp_content)
175+
176+
# Should use the exception's status_code
177+
assert invoke_response.status_code == 422
178+
assert "ExceptionWithValidStatusCode" in invoke_response.status_code_text
179+
assert "Test exception with valid status_code" in invoke_response.status_code_text
180+
181+
182+
@pytest.mark.parametrize(
183+
"file_data", mock_file_data, ids=[type(fd).__name__ for fd in mock_file_data]
184+
)
185+
def test_exception_without_status_code(file_data):
186+
"""Test that exceptions without status_code attribute are handled correctly."""
187+
from test.assets.exception_status_code import (
188+
function_raises_exception_without_status_code as test_fn,
189+
)
190+
191+
client = TestClient(wrap_in_fastapi(func=test_fn, plugin_id="mock_plugin"))
192+
193+
post_body = {"file_data": file_data.model_dump()}
194+
resp = client.post("/invoke", json=post_body)
195+
resp_content = resp.json()
196+
invoke_response = InvokeResponse.model_validate(resp_content)
197+
198+
# Should default to 500 when no status_code attribute
199+
assert invoke_response.status_code == 500
200+
assert "ExceptionWithoutStatusCode" in invoke_response.status_code_text
201+
assert "Test exception without status_code" in invoke_response.status_code_text
202+
203+
204+
@pytest.mark.parametrize(
205+
"file_data", mock_file_data, ids=[type(fd).__name__ for fd in mock_file_data]
206+
)
207+
def test_http_exception_handling(file_data):
208+
"""Test that HTTPException is handled correctly (should use HTTPException path)."""
209+
from test.assets.exception_status_code import function_raises_http_exception as test_fn
210+
211+
client = TestClient(wrap_in_fastapi(func=test_fn, plugin_id="mock_plugin"))
212+
213+
post_body = {"file_data": file_data.model_dump()}
214+
resp = client.post("/invoke", json=post_body)
215+
resp_content = resp.json()
216+
invoke_response = InvokeResponse.model_validate(resp_content)
217+
218+
# HTTPException should be handled by the HTTPException handler
219+
assert invoke_response.status_code == 404
220+
assert invoke_response.status_code_text == "Not found"
221+
222+
223+
@pytest.mark.parametrize(
224+
"file_data", mock_file_data, ids=[type(fd).__name__ for fd in mock_file_data]
225+
)
226+
def test_generic_exception_handling(file_data):
227+
"""Test that generic exceptions are handled correctly."""
228+
from test.assets.exception_status_code import function_raises_generic_exception as test_fn
229+
230+
client = TestClient(wrap_in_fastapi(func=test_fn, plugin_id="mock_plugin"))
231+
232+
post_body = {"file_data": file_data.model_dump()}
233+
resp = client.post("/invoke", json=post_body)
234+
resp_content = resp.json()
235+
invoke_response = InvokeResponse.model_validate(resp_content)
236+
237+
# Should default to 500 for generic exceptions
238+
assert invoke_response.status_code == 500
239+
assert "ValueError" in invoke_response.status_code_text
240+
assert "Generic error" in invoke_response.status_code_text
241+
242+
243+
@pytest.mark.parametrize(
244+
"file_data", mock_file_data, ids=[type(fd).__name__ for fd in mock_file_data]
245+
)
246+
def test_async_exception_with_none_status_code(file_data):
247+
"""Test that async functions with status_code=None exceptions are handled correctly."""
248+
from test.assets.exception_status_code import (
249+
async_function_raises_exception_with_none_status_code as test_fn,
250+
)
251+
252+
client = TestClient(wrap_in_fastapi(func=test_fn, plugin_id="mock_plugin"))
253+
254+
post_body = {"file_data": file_data.model_dump()}
255+
resp = client.post("/invoke", json=post_body)
256+
resp_content = resp.json()
257+
invoke_response = InvokeResponse.model_validate(resp_content)
258+
259+
# Should default to 500 when status_code is None
260+
assert invoke_response.status_code == 500
261+
assert "ExceptionWithNoneStatusCode" in invoke_response.status_code_text
262+
assert "Async test exception with None status_code" in invoke_response.status_code_text
263+
264+
265+
def test_streaming_exception_with_none_status_code():
266+
"""Test that async generator functions with
267+
status_code=None exceptions are handled correctly."""
268+
from test.assets.exception_status_code import (
269+
async_gen_function_raises_exception_with_none_status_code as test_fn,
270+
)
271+
272+
client = TestClient(wrap_in_fastapi(func=test_fn, plugin_id="mock_plugin"))
273+
274+
post_body = {"file_data": mock_file_data[0].model_dump()}
275+
resp = client.post("/invoke", json=post_body)
276+
277+
# For streaming responses, we get NDJSON
278+
assert resp.status_code == 200
279+
assert resp.headers["content-type"] == "application/x-ndjson"
280+
281+
# Parse the streaming response - should be a single error response
282+
lines = resp.content.decode().strip().split("\n")
283+
assert len(lines) == 1 # Only error response since no items were yielded
284+
285+
# Parse the error response
286+
import json
287+
288+
error_response = json.loads(lines[0])
289+
invoke_response = InvokeResponse.model_validate(error_response)
290+
291+
# Should default to 500 when status_code is None
292+
assert invoke_response.status_code == 500
293+
assert "ExceptionWithNoneStatusCode" in invoke_response.status_code_text
294+
295+
296+
def test_streaming_exception_with_valid_status_code():
297+
"""Test that async generator functions with
298+
valid status_code exceptions are handled correctly."""
299+
from test.assets.exception_status_code import (
300+
async_gen_function_raises_exception_with_valid_status_code as test_fn,
301+
)
302+
303+
client = TestClient(wrap_in_fastapi(func=test_fn, plugin_id="mock_plugin"))
304+
305+
post_body = {"file_data": mock_file_data[0].model_dump()}
306+
resp = client.post("/invoke", json=post_body)
307+
308+
# For streaming responses, we get NDJSON
309+
assert resp.status_code == 200
310+
assert resp.headers["content-type"] == "application/x-ndjson"
311+
312+
# Parse the streaming response - should be a single error response
313+
lines = resp.content.decode().strip().split("\n")
314+
assert len(lines) == 1 # Only error response since no items were yielded
315+
316+
# Parse the error response
317+
import json
318+
319+
error_response = json.loads(lines[0])
320+
invoke_response = InvokeResponse.model_validate(error_response)
321+
322+
# Should use the exception's status_code
323+
assert invoke_response.status_code == 422
324+
assert "ExceptionWithValidStatusCode" in invoke_response.status_code_text
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
"""Test assets for testing exception handling with various status_code scenarios."""
2+
3+
from fastapi import HTTPException
4+
5+
6+
class ExceptionWithNoneStatusCode(Exception):
7+
"""Exception that has status_code attribute set to None."""
8+
9+
def __init__(self, message: str):
10+
super().__init__(message)
11+
self.status_code = None
12+
13+
14+
class ExceptionWithValidStatusCode(Exception):
15+
"""Exception that has status_code attribute set to a valid integer."""
16+
17+
def __init__(self, message: str, status_code: int = 400):
18+
super().__init__(message)
19+
self.status_code = status_code
20+
21+
22+
class ExceptionWithoutStatusCode(Exception):
23+
"""Exception that has no status_code attribute."""
24+
25+
def __init__(self, message: str):
26+
super().__init__(message)
27+
28+
29+
def function_raises_exception_with_none_status_code():
30+
"""Function that raises an exception with status_code=None."""
31+
raise ExceptionWithNoneStatusCode("Test exception with None status_code")
32+
33+
34+
def function_raises_exception_with_valid_status_code():
35+
"""Function that raises an exception with valid status_code."""
36+
raise ExceptionWithValidStatusCode("Test exception with valid status_code", 422)
37+
38+
39+
def function_raises_exception_without_status_code():
40+
"""Function that raises an exception without status_code attribute."""
41+
raise ExceptionWithoutStatusCode("Test exception without status_code")
42+
43+
44+
def function_raises_http_exception():
45+
"""Function that raises FastAPI HTTPException."""
46+
raise HTTPException(status_code=404, detail="Not found")
47+
48+
49+
def function_raises_generic_exception():
50+
"""Function that raises a generic exception."""
51+
raise ValueError("Generic error")
52+
53+
54+
# Async versions for streaming response tests
55+
async def async_function_raises_exception_with_none_status_code():
56+
"""Async function that raises an exception with status_code=None."""
57+
raise ExceptionWithNoneStatusCode("Async test exception with None status_code")
58+
59+
60+
async def async_function_raises_exception_with_valid_status_code():
61+
"""Async function that raises an exception with valid status_code."""
62+
raise ExceptionWithValidStatusCode("Async test exception with valid status_code", 422)
63+
64+
65+
async def async_function_raises_exception_without_status_code():
66+
"""Async function that raises an exception without status_code attribute."""
67+
raise ExceptionWithoutStatusCode("Async test exception without status_code")
68+
69+
70+
# Async generator versions for streaming response error tests
71+
async def async_gen_function_raises_exception_with_none_status_code():
72+
"""Async generator that raises an exception with status_code=None."""
73+
# Don't yield anything, just raise the exception
74+
if False: # This ensures the function is detected as a generator but never yields
75+
yield None
76+
raise ExceptionWithNoneStatusCode("Async gen test exception with None status_code")
77+
78+
79+
async def async_gen_function_raises_exception_with_valid_status_code():
80+
"""Async generator that raises an exception with valid status_code."""
81+
# Don't yield anything, just raise the exception
82+
if False: # This ensures the function is detected as a generator but never yields
83+
yield None
84+
raise ExceptionWithValidStatusCode("Async gen test exception with valid status_code", 422)
85+
86+
87+
async def async_gen_function_raises_exception_without_status_code():
88+
"""Async generator that raises an exception without status_code attribute."""
89+
# Don't yield anything, just raise the exception
90+
if False: # This ensures the function is detected as a generator but never yields
91+
yield None
92+
raise ExceptionWithoutStatusCode("Async gen test exception without status_code")
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "0.0.39" # pragma: no cover
1+
__version__ = "0.0.40" # pragma: no cover

unstructured_platform_plugins/etl_uvicorn/api_generator.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -191,9 +191,8 @@ async def _stream_response():
191191
filedata_meta=filedata_meta_model.model_validate(
192192
filedata_meta.model_dump()
193193
),
194-
status_code=e.status_code
195-
if hasattr(e, "status_code")
196-
else status.HTTP_500_INTERNAL_SERVER_ERROR,
194+
status_code=getattr(e, "status_code", None)
195+
or status.HTTP_500_INTERNAL_SERVER_ERROR,
197196
status_code_text=f"[{e.__class__.__name__}] {e}",
198197
).model_dump_json()
199198
+ "\n"
@@ -230,9 +229,8 @@ async def _stream_response():
230229
usage=usage,
231230
message_channels=message_channels,
232231
filedata_meta=filedata_meta_model.model_validate(filedata_meta.model_dump()),
233-
status_code=invoke_error.status_code
234-
if hasattr(invoke_error, "status_code")
235-
else status.HTTP_500_INTERNAL_SERVER_ERROR,
232+
status_code=getattr(invoke_error, "status_code", None)
233+
or status.HTTP_500_INTERNAL_SERVER_ERROR,
236234
status_code_text=f"[{invoke_error.__class__.__name__}] {invoke_error}",
237235
file_data=request_dict.get("file_data", None),
238236
)

0 commit comments

Comments
 (0)