diff --git a/docs/instructions.md b/docs/instructions.md index ebc04a4..6c36fdb 100644 --- a/docs/instructions.md +++ b/docs/instructions.md @@ -119,56 +119,86 @@ For each task completed: ## TODO List: -✅ **Task Completed Successfully** +fix: -### Issue Fixed: TypeError: 'async for' requires an object with **aiter** method +INFO: 127.0.0.1:34916 - "POST /api/anime/search HTTP/1.1" 200 +INFO: 127.0.0.1:34916 - "POST /api/anime/add HTTP/1.1" 500 +ERROR: Exception in ASGI application +Traceback (most recent call last): +File "/home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/anyio/streams/memory.py", line 98, in receive +return self.receive_nowait() +~~~~~~~~~~~~~~~~~~~^^ +File "/home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/anyio/streams/memory.py", line 93, in receive_nowait +raise WouldBlock +anyio.WouldBlock -**Problem:** -The BackgroundLoaderService was crashing when trying to load series data with the error: +During handling of the above exception, another exception occurred: -``` -TypeError: 'async for' requires an object with __aiter__ method, got _AsyncGeneratorContextManager -``` +Traceback (most recent call last): +File "/home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/starlette/middleware/base.py", line 78, in call_next +message = await recv_stream.receive() +^^^^^^^^^^^^^^^^^^^^^^^^^^^ +File "/home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/anyio/streams/memory.py", line 118, in receive +raise EndOfStream +anyio.EndOfStream -This error occurred in `_load_series_data` method at line 282 of [background_loader_service.py](src/server/services/background_loader_service.py). +During handling of the above exception, another exception occurred: -**Root Cause:** -The code was incorrectly using `async for db in get_db_session():` to get a database session. However, `get_db_session()` is decorated with `@asynccontextmanager`, which returns an async context manager (not an async iterator). Async context managers must be used with `async with`, not `async for`. - -**Solution:** -Changed the database session acquisition from: - -```python -async for db in get_db_session(): - # ... code ... - break # Exit loop after first iteration -``` - -To the correct pattern: - -```python -async with get_db_session() as db: - # ... code ... -``` - -**Files Modified:** - -1. [src/server/services/background_loader_service.py](src/server/services/background_loader_service.py) - Fixed async context manager usage -2. [tests/unit/test_background_loader_session.py](tests/unit/test_background_loader_session.py) - Created comprehensive unit tests - -**Tests:** - -- ✅ 5 new unit tests for background loader database session handling (all passing) -- ✅ Tests verify proper async context manager usage -- ✅ Tests verify error handling and progress tracking -- ✅ Tests verify episode and NFO loading logic -- ✅ Includes test demonstrating the difference between `async with` and `async for` - -**Verification:** -The fix allows the background loader service to properly load series data including episodes, NFO files, logos, and images without crashing. - ---- - -## Next Tasks: - -No pending tasks at this time. +Traceback (most recent call last): +File "/home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/uvicorn/protocols/http/httptools_impl.py", line 426, in run_asgi +result = await app( # type: ignore[func-returns-value] +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +self.scope, self.receive, self.send +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +) +^ +File "/home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/uvicorn/middleware/proxy_headers.py", line 84, in **call** +return await self.app(scope, receive, send) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +File "/home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/fastapi/applications.py", line 1106, in **call** +await super().**call**(scope, receive, send) +File "/home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/starlette/applications.py", line 122, in **call** +await self.middleware_stack(scope, receive, send) +File "/home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/starlette/middleware/errors.py", line 184, in **call** +raise exc +File "/home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/starlette/middleware/errors.py", line 162, in **call** +await self.app(scope, receive, \_send) +File "/home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/starlette/middleware/base.py", line 108, in **call** +response = await self.dispatch_func(request, call_next) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +File "/home/lukas/Volume/repo/Aniworld/src/server/middleware/auth.py", line 209, in dispatch +return await call_next(request) +^^^^^^^^^^^^^^^^^^^^^^^^ +File "/home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/starlette/middleware/base.py", line 84, in call_next +raise app_exc +File "/home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/starlette/middleware/base.py", line 70, in coro +await self.app(scope, receive_or_disconnect, send_no_error) +File "/home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/starlette/middleware/base.py", line 108, in **call** +response = await self.dispatch_func(request, call_next) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +File "/home/lukas/Volume/repo/Aniworld/src/server/middleware/setup_redirect.py", line 120, in dispatch +return await call_next(request) +^^^^^^^^^^^^^^^^^^^^^^^^ +File "/home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/starlette/middleware/base.py", line 84, in call_next +raise app_exc +File "/home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/starlette/middleware/base.py", line 70, in coro +await self.app(scope, receive_or_disconnect, send_no_error) +File "/home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/starlette/middleware/cors.py", line 91, in **call** +await self.simple_response(scope, receive, send, request_headers=headers) +File "/home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/starlette/middleware/cors.py", line 146, in simple_response +await self.app(scope, receive, send) +File "/home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/starlette/middleware/exceptions.py", line 79, in **call** +raise exc +File "/home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/starlette/middleware/exceptions.py", line 68, in **call** +await self.app(scope, receive, sender) +File "/home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/fastapi/middleware/asyncexitstack.py", line 14, in **call** +async with AsyncExitStack() as stack: +~~~~~~~~~~~~~~^^ +File "/home/lukas/miniconda3/envs/AniWorld/lib/python3.13/contextlib.py", line 768, in **aexit** +raise exc +File "/home/lukas/miniconda3/envs/AniWorld/lib/python3.13/contextlib.py", line 751, in **aexit** +cb_suppress = await cb(\*exc_details) +^^^^^^^^^^^^^^^^^^^^^^ +File "/home/lukas/miniconda3/envs/AniWorld/lib/python3.13/contextlib.py", line 271, in **aexit** +raise RuntimeError("generator didn't stop after athrow()") +RuntimeError: generator didn't stop after athrow() diff --git a/src/server/utils/dependencies.py b/src/server/utils/dependencies.py index 7989c22..c7c8ebc 100644 --- a/src/server/utils/dependencies.py +++ b/src/server/utils/dependencies.py @@ -170,12 +170,7 @@ async def get_optional_database_session() -> AsyncGenerator: from src.server.database import get_db_session async with get_db_session() as session: - try: - yield session - except Exception: - # Re-raise the exception to let FastAPI handle it - # This prevents "generator didn't stop after athrow()" error - raise + yield session except (ImportError, RuntimeError): # Database not available - yield None yield None diff --git a/tests/unit/test_dependencies.py b/tests/unit/test_dependencies.py index 1680cab..bff63e9 100644 --- a/tests/unit/test_dependencies.py +++ b/tests/unit/test_dependencies.py @@ -16,6 +16,7 @@ from src.server.utils.dependencies import ( common_parameters, get_current_user, get_database_session, + get_optional_database_session, get_series_app, log_request_dependency, optional_auth, @@ -291,6 +292,156 @@ class TestUtilityDependencies: # Assert - no exception should be raised +class TestOptionalDatabaseSession: + """Test cases for optional database session dependency.""" + + @pytest.mark.asyncio + async def test_optional_database_session_success(self): + """Test successful database session creation.""" + from unittest.mock import AsyncMock + + # Mock the database session + mock_session = AsyncMock() + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=None) + + mock_get_db = Mock(return_value=mock_session) + + with patch('src.server.database.get_db_session', mock_get_db): + # Act + gen = get_optional_database_session() + session = await gen.__anext__() + + # Assert + assert session is mock_session + mock_get_db.assert_called_once() + + # Cleanup + try: + await gen.aclose() + except StopAsyncIteration: + pass + + @pytest.mark.asyncio + async def test_optional_database_session_not_available(self): + """Test when database is not available (ImportError).""" + # Mock ImportError when trying to import get_db_session + with patch( + 'src.server.database.get_db_session', + side_effect=ImportError("No module named 'database'") + ): + # Act + gen = get_optional_database_session() + session = await gen.__anext__() + + # Assert - should return None when database not available + assert session is None + + # Cleanup + try: + await gen.aclose() + except StopAsyncIteration: + pass + + @pytest.mark.asyncio + async def test_optional_database_session_runtime_error(self): + """Test when database raises RuntimeError.""" + # Mock RuntimeError when trying to get database session + with patch( + 'src.server.database.get_db_session', + side_effect=RuntimeError("Database connection failed") + ): + # Act + gen = get_optional_database_session() + session = await gen.__anext__() + + # Assert - should return None on RuntimeError + assert session is None + + # Cleanup + try: + await gen.aclose() + except StopAsyncIteration: + pass + + @pytest.mark.asyncio + async def test_optional_database_session_exception_during_use(self): + """ + Test that exceptions during database operations are properly propagated. + + This test specifically addresses the "generator didn't stop after athrow()" + error that occurred when exceptions were caught and re-raised within the + yield block of an async context manager. + """ + from unittest.mock import AsyncMock + + # Create a mock session that will raise an exception when used + mock_session = AsyncMock() + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=None) + + # Mock the get_db_session to return our mock session + mock_get_db = Mock(return_value=mock_session) + + with patch('src.server.database.get_db_session', mock_get_db): + # Act & Assert + gen = get_optional_database_session() + session = await gen.__anext__() + + assert session is mock_session + + # Simulate an exception being thrown into the generator + # This mimics what happens when an endpoint raises an exception + # after the dependency has yielded + test_exception = ValueError("Database operation failed") + + try: + # This should not cause "generator didn't stop after athrow()" error + await gen.athrow(test_exception) + # If we get here, the exception was swallowed (shouldn't happen) + pytest.fail("Exception should have been propagated") + except ValueError as e: + # Exception should be properly propagated + assert str(e) == "Database operation failed" + except StopAsyncIteration: + # Generator stopped normally after exception + pass + + @pytest.mark.asyncio + async def test_optional_database_session_cleanup_on_exception(self): + """Test that database session is properly cleaned up when exception occurs.""" + from unittest.mock import AsyncMock + + # Track cleanup + cleanup_called = [] + + async def mock_exit(*args): + cleanup_called.append(True) + return None + + # Create a mock session with tracked cleanup + mock_session = AsyncMock() + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = mock_exit + + mock_get_db = Mock(return_value=mock_session) + + with patch('src.server.database.get_db_session', mock_get_db): + gen = get_optional_database_session() + session = await gen.__anext__() + + assert session is mock_session + + # Throw an exception to simulate endpoint failure + try: + await gen.athrow(RuntimeError("Simulated endpoint error")) + except (RuntimeError, StopAsyncIteration): + pass + + # Assert cleanup was called + assert len(cleanup_called) > 0, "Session cleanup should have been called" + + class TestIntegrationScenarios: """Integration test scenarios for dependency injection."""