Skip to content

Commit ac4145a

Browse files
committed
fix(testing): handle uvicorn SystemExit in EllarUvicornServer startup
After upgrading uvicorn, the test server was failing because uvicorn now calls sys.exit(1) when encountering port binding errors. This caused the entire test process to terminate instead of handling errors gracefully. Changes: - Override startup() method to catch SystemExit exceptions - Convert SystemExit to RuntimeError with descriptive message - Ensure proper cleanup via lifespan.shutdown() on errors - Remove setup_event_loop() call that interfered with running loop - Fix _startup_done event synchronization This allows tests to handle server startup failures properly without terminating the test process. Fixes socket_io test failures in TestGatewayWithGuards and related test classes.
1 parent 094e9c1 commit ac4145a

File tree

7 files changed

+369
-23
lines changed

7 files changed

+369
-23
lines changed

docs/overview/modules.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,10 +289,23 @@ class ApplicationModule(ModuleBase):
289289
The `JWTModule` provides `JWTService` which is injected into `CustomModule`. By declaring `ForwardRefModule(JWTModule)` in `CustomModule`, the `JWTService` will be properly resolved during instantiation of `CustomModule`, regardless of the order in which the modules are configured in the application.
290290

291291
This pattern is particularly useful when:
292+
292293
- You want to avoid direct module instantiation
293294
- You need to configure a module differently in different parts of your application
294295
- You want to maintain loose coupling between modules
295296

297+
### When not to use `ForwardRefModule`
298+
If the @Module class is registered in the application module, ApplicationModule, there is no need to use `ForwardRefModule`. Services/Providers exported from the module will be available in the application module. And all other modules will be able to resolve the dependencies from the application module.
299+
300+
```python
301+
from ellar.common import Module
302+
from ellar.core.modules import ForwardRefModule, ModuleBase
303+
304+
@Module(modules=[ModuleA])
305+
class ApplicationModule(ModuleBase):
306+
pass
307+
```
308+
296309
### Forward Reference by Class
297310

298311
In the following example, we have two modules, `ModuleA` and `ModuleB`. `ModuleB`
@@ -321,6 +334,7 @@ class ApplicationModule(ModuleBase):
321334
```
322335

323336
In this example:
337+
324338
- `ModuleB` references `ModuleA` using `ForwardRefModule`, meaning `ModuleB` knows about `ModuleA` but doesn't instantiate it.
325339
- When `ApplicationModule` is built, both `ModuleA` and `ModuleB` are instantiated. During this build process, `ModuleB` can reference the instance of `ModuleA`, ensuring that all dependencies are resolved properly.
326340

@@ -352,6 +366,7 @@ class ApplicationModule(ModuleBase):
352366
```
353367

354368
In this second example:
369+
355370
- `ModuleB` references `ModuleA` by its name, `"moduleA"`.
356371
- During the build process of `ApplicationModule`, the name reference is resolved, ensuring that `ModuleA` is instantiated and injected into `ModuleB` correctly.
357372

ellar/app/main.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import json
22
import logging
3-
import logging.config
43
import typing as t
4+
from contextlib import _AsyncGeneratorContextManager
55

66
from ellar.auth.handlers import AuthenticationHandlerType
77
from ellar.common import (
@@ -185,7 +185,7 @@ def request_context(
185185
)
186186

187187
@t.no_type_check
188-
def with_injector_context(self) -> t.AsyncGenerator[EllarInjector, t.Any]:
188+
def with_injector_context(self) -> _AsyncGeneratorContextManager[t.Any]:
189189
return injector_context(self.injector)
190190

191191
@t.no_type_check

ellar/auth/session/__init__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,15 @@ def session_cookie_options(self) -> SessionCookieOption: # pragma: no cover
2323
return SessionCookieOption()
2424

2525
def deserialize_session(
26-
self, session_data: t.Optional[str]
26+
self,
27+
session_data: t.Optional[str],
28+
config: t.Optional[SessionCookieOption] = None,
2729
) -> SessionCookieObject: # pragma: no cover
2830
return SessionCookieObject()
2931

3032
def serialize_session(
3133
self,
3234
session: t.Union[str, SessionCookieObject],
35+
config: t.Optional[SessionCookieOption] = None,
3336
) -> str: # pragma: no cover
3437
return ""

ellar/auth/session/base.py

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,37 +17,48 @@ def session_cookie_options(self) -> SessionCookieOption:
1717
def serialize_session(
1818
self,
1919
session: t.Union[str, SessionCookieObject],
20+
config: t.Optional[SessionCookieOption] = None,
2021
) -> str:
2122
"""
2223
:param session: Collection ExtraEndpointArg
24+
:param config: SessionCookieOption
2325
:return: string
2426
"""
2527

2628
@abstractmethod
27-
def deserialize_session(self, session_data: t.Optional[str]) -> SessionCookieObject:
29+
def deserialize_session(
30+
self,
31+
session_data: t.Optional[str],
32+
config: t.Optional[SessionCookieOption] = None,
33+
) -> SessionCookieObject:
2834
"""
2935
:param session_data:
3036
:return: SessionCookieObject
37+
:param config: SessionCookieOption
3138
"""
3239

33-
def get_cookie_header_value(self, data: t.Any, delete: bool = False) -> str:
34-
security_flags = "httponly; samesite=" + self.session_cookie_options.SAME_SITE
35-
if self.session_cookie_options.SECURE:
40+
def get_cookie_header_value(
41+
self,
42+
data: t.Any,
43+
delete: bool = False,
44+
config: t.Optional[SessionCookieOption] = None,
45+
) -> str:
46+
session_config = config or self.session_cookie_options
47+
security_flags = "httponly; samesite=" + session_config.SAME_SITE
48+
if session_config.SECURE:
3649
security_flags += "; secure"
3750

3851
if not delete:
3952
max_age = (
40-
f"Max-Age={self.session_cookie_options.MAX_AGE}; "
41-
if self.session_cookie_options.MAX_AGE
42-
else ""
53+
f"Max-Age={session_config.MAX_AGE}; " if session_config.MAX_AGE else ""
4354
)
4455
else:
4556
max_age = "Max-Age=0; "
4657

4758
header_value = "{session_cookie}={data}; path={path}; {max_age}{security_flags}".format( # E501
48-
session_cookie=self.session_cookie_options.NAME,
59+
session_cookie=session_config.NAME,
4960
data=data,
50-
path=self.session_cookie_options.PATH,
61+
path=session_config.PATH,
5162
max_age=max_age,
5263
security_flags=security_flags,
5364
)

ellar/auth/session/strategy.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,22 +40,29 @@ def session_cookie_options(self) -> SessionCookieOption:
4040
def serialize_session(
4141
self,
4242
session: t.Union[str, SessionCookieObject],
43+
config: t.Optional[SessionCookieOption] = None,
4344
) -> str:
45+
session_config = config or self._session_config
4446
if isinstance(session, SessionCookieObject):
4547
data = b64encode(json.dumps(dict(session)).encode("utf-8"))
4648
data = self._signer.sign(data)
4749

48-
return self.get_cookie_header_value(data.decode("utf-8"))
50+
return self.get_cookie_header_value(
51+
data.decode("utf-8"), config=session_config
52+
)
4953

50-
return self.get_cookie_header_value(session, delete=True)
54+
return self.get_cookie_header_value(session, delete=True, config=session_config)
5155

52-
def deserialize_session(self, session_data: t.Optional[str]) -> SessionCookieObject:
56+
def deserialize_session(
57+
self,
58+
session_data: t.Optional[str],
59+
config: t.Optional[SessionCookieOption] = None,
60+
) -> SessionCookieObject:
61+
session_config = config or self._session_config
5362
if session_data:
5463
data = session_data.encode("utf-8")
5564
try:
56-
data = self._signer.unsign(
57-
data, max_age=self.session_cookie_options.MAX_AGE
58-
)
65+
data = self._signer.unsign(data, max_age=session_config.MAX_AGE)
5966
return SessionCookieObject(json.loads(b64decode(data)))
6067
except BadSignature:
6168
pass

ellar/testing/uvicorn_server.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,17 @@ def __init__(self, app: ASGIApp, host: str = "127.0.0.1", port: int = 8000):
2525
super().__init__(config=uvicorn.Config(app, host=host, port=port))
2626

2727
async def startup(self, sockets: t.Optional[t.List[t.Any]] = None) -> None:
28-
"""Override uvicorn startup"""
29-
await super().startup(sockets)
30-
self.config.setup_event_loop()
31-
self._startup_done.set()
28+
"""Override uvicorn startup to prevent sys.exit on errors"""
29+
try:
30+
await super().startup(sockets)
31+
self._startup_done.set()
32+
except SystemExit as exc:
33+
# Convert sys.exit() calls into RuntimeError for testing
34+
# This happens when uvicorn fails to bind to a port
35+
await self.lifespan.shutdown()
36+
raise RuntimeError(
37+
f"Server failed to start on {self.config.host}:{self.config.port}"
38+
) from exc
3239

3340
async def run_server(self) -> None:
3441
"""Start up server asynchronously"""

0 commit comments

Comments
 (0)