diff --git a/test/api/test_api.py b/test/api/test_api.py index 2bbf8e2..f8bca3c 100644 --- a/test/api/test_api.py +++ b/test/api/test_api.py @@ -322,3 +322,151 @@ def test_streaming_exception_with_valid_status_code(): # Should use the exception's status_code assert invoke_response.status_code == 422 assert "ExceptionWithValidStatusCode" in invoke_response.status_code_text + + +@pytest.mark.parametrize( + "file_data", mock_file_data, ids=[type(fd).__name__ for fd in mock_file_data] +) +def test_unstructured_ingest_error_with_status_code(file_data): + """Test that UnstructuredIngestError with status_code is handled correctly.""" + from test.assets.exception_status_code import ( + function_raises_unstructured_ingest_error_with_status_code as test_fn, + ) + + client = TestClient(wrap_in_fastapi(func=test_fn, plugin_id="mock_plugin")) + + post_body = {"file_data": file_data.model_dump()} + resp = client.post("/invoke", json=post_body) + resp_content = resp.json() + invoke_response = InvokeResponse.model_validate(resp_content) + + # Should use the UnstructuredIngestError's status_code + assert invoke_response.status_code == 400 + assert invoke_response.status_code_text == "Test UnstructuredIngestError with status_code" + + +@pytest.mark.parametrize( + "file_data", mock_file_data, ids=[type(fd).__name__ for fd in mock_file_data] +) +def test_unstructured_ingest_error_without_status_code(file_data): + """Test that UnstructuredIngestError without status_code defaults to 500.""" + from test.assets.exception_status_code import ( + function_raises_unstructured_ingest_error_without_status_code as test_fn, + ) + + client = TestClient(wrap_in_fastapi(func=test_fn, plugin_id="mock_plugin")) + + post_body = {"file_data": file_data.model_dump()} + resp = client.post("/invoke", json=post_body) + resp_content = resp.json() + invoke_response = InvokeResponse.model_validate(resp_content) + + # Should default to 500 when UnstructuredIngestError has no status_code + assert invoke_response.status_code == 500 + assert invoke_response.status_code_text == "Test UnstructuredIngestError without status_code" + + +@pytest.mark.parametrize( + "file_data", mock_file_data, ids=[type(fd).__name__ for fd in mock_file_data] +) +def test_unstructured_ingest_error_with_none_status_code(file_data): + """Test that UnstructuredIngestError with None status_code defaults to 500.""" + from test.assets.exception_status_code import ( + function_raises_unstructured_ingest_error_with_none_status_code as test_fn, + ) + + client = TestClient(wrap_in_fastapi(func=test_fn, plugin_id="mock_plugin")) + + post_body = {"file_data": file_data.model_dump()} + resp = client.post("/invoke", json=post_body) + resp_content = resp.json() + invoke_response = InvokeResponse.model_validate(resp_content) + + # Should default to 500 when UnstructuredIngestError status_code is None + assert invoke_response.status_code == 500 + assert invoke_response.status_code_text == "Test UnstructuredIngestError with None status_code" + + +@pytest.mark.parametrize( + "file_data", mock_file_data, ids=[type(fd).__name__ for fd in mock_file_data] +) +def test_async_unstructured_ingest_error(file_data): + """Test that async functions with UnstructuredIngestError are handled correctly.""" + from test.assets.exception_status_code import ( + async_function_raises_unstructured_ingest_error as test_fn, + ) + + client = TestClient(wrap_in_fastapi(func=test_fn, plugin_id="mock_plugin")) + + post_body = {"file_data": file_data.model_dump()} + resp = client.post("/invoke", json=post_body) + resp_content = resp.json() + invoke_response = InvokeResponse.model_validate(resp_content) + + # Should use the UnstructuredIngestError's status_code + assert invoke_response.status_code == 503 + assert invoke_response.status_code_text == "Async test UnstructuredIngestError" + + +def test_streaming_unstructured_ingest_error(): + """Test that async generator functions with UnstructuredIngestError are handled correctly.""" + from test.assets.exception_status_code import ( + async_gen_function_raises_unstructured_ingest_error as test_fn, + ) + + client = TestClient(wrap_in_fastapi(func=test_fn, plugin_id="mock_plugin")) + + post_body = {"file_data": mock_file_data[0].model_dump()} + resp = client.post("/invoke", json=post_body) + + # For streaming responses, we get NDJSON + assert resp.status_code == 200 + assert resp.headers["content-type"] == "application/x-ndjson" + + # Parse the streaming response - should be a single error response + lines = resp.content.decode().strip().split("\n") + assert len(lines) == 1 # Only error response since no items were yielded + + # Parse the error response + import json + + error_response = json.loads(lines[0]) + invoke_response = InvokeResponse.model_validate(error_response) + + # Should use the UnstructuredIngestError's status_code + assert invoke_response.status_code == 502 + assert "Async gen test UnstructuredIngestError" in invoke_response.status_code_text + + +def test_streaming_unstructured_ingest_error_with_none_status_code(): + """Test that async generator functions with UnstructuredIngestError + with None status_code are handled correctly.""" + from test.assets.exception_status_code import ( + async_gen_function_raises_unstructured_ingest_error_with_none_status_code as test_fn, + ) + + client = TestClient(wrap_in_fastapi(func=test_fn, plugin_id="mock_plugin")) + + post_body = {"file_data": mock_file_data[0].model_dump()} + resp = client.post("/invoke", json=post_body) + + # For streaming responses, we get NDJSON + assert resp.status_code == 200 + assert resp.headers["content-type"] == "application/x-ndjson" + + # Parse the streaming response - should be a single error response + lines = resp.content.decode().strip().split("\n") + assert len(lines) == 1 # Only error response since no items were yielded + + # Parse the error response + import json + + error_response = json.loads(lines[0]) + invoke_response = InvokeResponse.model_validate(error_response) + + # Should default to 500 when UnstructuredIngestError status_code is None + assert invoke_response.status_code == 500 + assert ( + "Async gen test UnstructuredIngestError with None status_code" + in invoke_response.status_code_text + ) diff --git a/test/assets/exception_status_code.py b/test/assets/exception_status_code.py index f1f64b3..6e35997 100644 --- a/test/assets/exception_status_code.py +++ b/test/assets/exception_status_code.py @@ -1,6 +1,7 @@ """Test assets for testing exception handling with various status_code scenarios.""" from fastapi import HTTPException +from unstructured_ingest.error import UnstructuredIngestError class ExceptionWithNoneStatusCode(Exception): @@ -51,6 +52,25 @@ def function_raises_generic_exception(): raise ValueError("Generic error") +def function_raises_unstructured_ingest_error_with_status_code(): + """Function that raises UnstructuredIngestError with status_code.""" + error = UnstructuredIngestError("Test UnstructuredIngestError with status_code") + error.status_code = 400 + raise error + + +def function_raises_unstructured_ingest_error_without_status_code(): + """Function that raises UnstructuredIngestError without status_code.""" + raise UnstructuredIngestError("Test UnstructuredIngestError without status_code") + + +def function_raises_unstructured_ingest_error_with_none_status_code(): + """Function that raises UnstructuredIngestError with None status_code.""" + error = UnstructuredIngestError("Test UnstructuredIngestError with None status_code") + error.status_code = None + raise error + + # Async versions for streaming response tests async def async_function_raises_exception_with_none_status_code(): """Async function that raises an exception with status_code=None.""" @@ -67,6 +87,13 @@ async def async_function_raises_exception_without_status_code(): raise ExceptionWithoutStatusCode("Async test exception without status_code") +async def async_function_raises_unstructured_ingest_error(): + """Async function that raises UnstructuredIngestError.""" + error = UnstructuredIngestError("Async test UnstructuredIngestError") + error.status_code = 503 + raise error + + # Async generator versions for streaming response error tests async def async_gen_function_raises_exception_with_none_status_code(): """Async generator that raises an exception with status_code=None.""" @@ -90,3 +117,23 @@ async def async_gen_function_raises_exception_without_status_code(): if False: # This ensures the function is detected as a generator but never yields yield None raise ExceptionWithoutStatusCode("Async gen test exception without status_code") + + +async def async_gen_function_raises_unstructured_ingest_error(): + """Async generator that raises UnstructuredIngestError.""" + # Don't yield anything, just raise the exception + if False: # This ensures the function is detected as a generator but never yields + yield None + error = UnstructuredIngestError("Async gen test UnstructuredIngestError") + error.status_code = 502 + raise error + + +async def async_gen_function_raises_unstructured_ingest_error_with_none_status_code(): + """Async generator that raises UnstructuredIngestError with None status_code.""" + # Don't yield anything, just raise the exception + if False: # This ensures the function is detected as a generator but never yields + yield None + error = UnstructuredIngestError("Async gen test UnstructuredIngestError with None status_code") + error.status_code = None + raise error diff --git a/unstructured_platform_plugins/__version__.py b/unstructured_platform_plugins/__version__.py index c51693f..592359c 100644 --- a/unstructured_platform_plugins/__version__.py +++ b/unstructured_platform_plugins/__version__.py @@ -1 +1 @@ -__version__ = "0.0.40" # pragma: no cover +__version__ = "0.0.41" # pragma: no cover diff --git a/unstructured_platform_plugins/etl_uvicorn/api_generator.py b/unstructured_platform_plugins/etl_uvicorn/api_generator.py index 347b11a..4e9c119 100644 --- a/unstructured_platform_plugins/etl_uvicorn/api_generator.py +++ b/unstructured_platform_plugins/etl_uvicorn/api_generator.py @@ -12,6 +12,7 @@ from pydantic import BaseModel, Field, create_model from starlette.responses import RedirectResponse from unstructured_ingest.data_types.file_data import BatchFileData, FileData, file_data_from_dict +from unstructured_ingest.error import UnstructuredIngestError from uvicorn.config import LOG_LEVELS from uvicorn.importer import import_from_string @@ -223,6 +224,19 @@ async def _stream_response(): else exc.detail, file_data=request_dict.get("file_data", None), ) + except UnstructuredIngestError as exc: + logger.error( + f"UnstructuredIngestError: {str(exc)} (status_code={exc.status_code})", + exc_info=True, + ) + return InvokeResponse( + usage=usage, + message_channels=message_channels, + filedata_meta=filedata_meta_model.model_validate(filedata_meta.model_dump()), + status_code=exc.status_code or status.HTTP_500_INTERNAL_SERVER_ERROR, + status_code_text=str(exc), + file_data=request_dict.get("file_data", None), + ) except Exception as invoke_error: logger.error(f"failed to invoke plugin: {invoke_error}", exc_info=True) return InvokeResponse(