[Serve] Fix make_fastapi_class_based_view for inherited methods#59410
Conversation
Fixed a bug where subclassing OpenAiIngress and using make_fastapi_ingress would cause FastAPI to treat 'self' as a required query parameter. The issue was that inherited methods retained their parent class's __qualname__, causing make_fastapi_class_based_view to skip them during route transformation. The fix updates each decorated method's __qualname__ to match the new class so that make_fastapi_class_based_view properly recognizes all methods. Signed-off-by: Kourosh Hakhamaneshi <kourosh@anyscale.com>
There was a problem hiding this comment.
Code Review
This pull request effectively addresses a subtle bug in make_fastapi_ingress concerning inherited methods and their __qualname__. The fix is clean and well-commented. The accompanying tests are thorough, covering various inheritance scenarios, which significantly improves confidence in the solution. I have a few suggestions to make the new tests even more precise by using exact-match assertions for __qualname__ instead of substring checks. Overall, this is a great contribution.
| assert "MyCustomIngress" in method.__qualname__, ( | ||
| f"Method {method_name} should have MyCustomIngress in __qualname__, " | ||
| f"but got {method.__qualname__}" | ||
| ) |
There was a problem hiding this comment.
The assertion "MyCustomIngress" in method.__qualname__ is good, but we can make it more precise. Asserting for the exact __qualname__ ensures that the naming is constructed exactly as expected, making the test more robust against future changes. This would also provide a clearer failure message if the __qualname__ format changes unexpectedly.
| assert "MyCustomIngress" in method.__qualname__, ( | |
| f"Method {method_name} should have MyCustomIngress in __qualname__, " | |
| f"but got {method.__qualname__}" | |
| ) | |
| expected_qualname = f"{MyCustomIngress.__qualname__}.{method_name}" | |
| assert method.__qualname__ == expected_qualname, ( | |
| f"Method {method_name} should have __qualname__ '{expected_qualname}', " | |
| f"but got '{method.__qualname__}'" | |
| ) |
|
|
||
| # Check custom method has correct qualname | ||
| custom_method = ingress_cls.custom_endpoint | ||
| assert "MyCustomIngress" in custom_method.__qualname__ |
There was a problem hiding this comment.
For a more precise test, it's better to assert the exact __qualname__ instead of checking for a substring. This ensures the name is constructed exactly as expected and provides a more informative failure message.
| assert "MyCustomIngress" in custom_method.__qualname__ | |
| assert custom_method.__qualname__ == f"{MyCustomIngress.__qualname__}.custom_endpoint" |
|
|
||
| # Check inherited methods also have correct qualname | ||
| completions_method = ingress_cls.completions | ||
| assert "MyCustomIngress" in completions_method.__qualname__ |
There was a problem hiding this comment.
| assert "FinalIngress" in method.__qualname__, ( | ||
| f"Method {method_name} should have FinalIngress in __qualname__, " | ||
| f"but got {method.__qualname__}" | ||
| ) |
There was a problem hiding this comment.
To improve test precision, it's better to assert the exact __qualname__ rather than using a substring check. This will catch any unintended changes to the __qualname__ construction for deeply nested classes.
| assert "FinalIngress" in method.__qualname__, ( | |
| f"Method {method_name} should have FinalIngress in __qualname__, " | |
| f"but got {method.__qualname__}" | |
| ) | |
| expected_qualname = f"{FinalIngress.__qualname__}.{method_name}" | |
| assert method.__qualname__ == expected_qualname, ( | |
| f"Method {method_name} should have __qualname__ '{expected_qualname}', " | |
| f"but got '{method.__qualname__}'" | |
| ) |
|
|
||
| # Check that the class qualname matches the input class qualname | ||
| # (classes defined inside methods have full path in qualname) | ||
| assert ingress_cls.__qualname__ == MyCustomIngress.__qualname__ |
There was a problem hiding this comment.
Bug: Test assertion will fail due to unset __qualname__
The assertion ingress_cls.__qualname__ == MyCustomIngress.__qualname__ will fail. The make_fastapi_ingress function returns the result of serve.ingress(app)(new_cls), which is an ASGIIngressWrapper class. Looking at serve.ingress in python/ray/serve/api.py, only ASGIIngressWrapper.__name__ is explicitly set (to cls.__name__), but __qualname__ is not set. The ASGIIngressWrapper class gets its default __qualname__ from where it's defined inside the decorator function, which will be something like "ingress.<locals>.decorator.<locals>.ASGIIngressWrapper" — not equal to MyCustomIngress.__qualname__.
|
This behavior is surprising. Is it something normal Serve users could hit when inheriting decorated methods? I.e. any reason we aren't targeting a fix in Serve? |
|
Good point @eicherseiji. Let me see what the fix would look like from serve's perspective. I may have fixed it in the wrong layer. |
make_fastapi_class_based_view for inherited methods
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request addresses a bug in Ray Serve where inherited FastAPI-decorated methods were not being correctly identified, causing self to be treated as a query parameter. The fix cleverly resolves this by inspecting the class's full Method Resolution Order (MRO) instead of just the class itself. The change is well-supported by new unit tests for make_fastapi_ingress and new integration tests that validate both multi-level and direct inheritance scenarios. The code quality is high, and the fix is robust. I have one minor suggestion to make a new test assertion more specific.
| ingress_cls = make_fastapi_ingress( | ||
| MyCustomIngress, endpoint_map=custom_endpoints | ||
| ) | ||
|
|
||
| # Verify the class was created | ||
| assert ingress_cls is not None |
There was a problem hiding this comment.
The assertion assert ingress_cls is not None is a bit weak. To make this test more robust and directly verify the functionality, consider also asserting that the custom route (/custom) has been correctly registered on the FastAPI application. This will confirm that make_fastapi_ingress is handling the custom method as expected.
| ingress_cls = make_fastapi_ingress( | |
| MyCustomIngress, endpoint_map=custom_endpoints | |
| ) | |
| # Verify the class was created | |
| assert ingress_cls is not None | |
| app = FastAPI() | |
| ingress_cls = make_fastapi_ingress( | |
| MyCustomIngress, endpoint_map=custom_endpoints, app=app | |
| ) | |
| # Verify the class was created and the route is registered | |
| assert ingress_cls is not None | |
| route_paths = [route.path for route in app.routes if isinstance(route, APIRoute)] | |
| assert "/custom" in route_paths |
| route.endpoint.__qualname__.startswith(base.__qualname__ + ".") | ||
| for base in cls.__mro__ | ||
| if base is not object | ||
| ) |
There was a problem hiding this comment.
Bug: Nested class routes incorrectly matched via MRO prefix
The new prefix matching logic can incorrectly match routes from nested classes. If a parent class Parent contains a nested class Parent.Nested with decorated methods, and a Child(Parent) class is served, the check route.endpoint.__qualname__.startswith("Parent.") would match "Parent.Nested.method" even though Parent.Nested is not in Child's MRO. The old substring check cls.__qualname__ in route.endpoint.__qualname__ would correctly reject this case. This could cause routes from unrelated nested classes to have incorrect self injection when a class inheriting from their container is served.
| # (it checks if cls.__qualname__ is in route.endpoint.__qualname__). | ||
| # We keep the same __name__ and __qualname__ as the original class | ||
| # so that the new class properly represents the input class. | ||
| new_cls = type(cls.__name__, (cls,), class_dict) |
There was a problem hiding this comment.
Are these type assignments still needed?
There was a problem hiding this comment.
not strictly anymore, but I kept them here, because they keep the repr(cls) name nicer.
|
serve-side changes looks good one ques though: how does fastAPI resolves if the parent and child class have a same route? who will take preference - parent or child? or error? and do we replicate that same behavior in our code? |
It certainly doesn't error. Fast API registers both but will match the first registered one (in this case the parent). In our code we follow python's MRO logic to decide who to register (only one will be registered and that'd be the child) So it is a bit different. Example: from fastapi import FastAPI
from fastapi.routing import APIRoute
print('=== NATIVE FASTAPI (decorators on both parent & child) ===')
app1 = FastAPI()
class Parent1:
@app1.get('/test')
def method(self):
return 'parent'
class Child1(Parent1):
@app1.get('/test') # Same path, override
def method(self):
return 'child'
routes1 = [(r.path, r.endpoint.__qualname__) for r in app1.routes if isinstance(r, APIRoute)]
print(f'Routes: {routes1}')
print(f'Count: {len(routes1)}')
print('-> BOTH registered, PARENT wins (first match)')
print()
print('=== OUR CODE (make_fastapi_ingress approach) ===')
app2 = FastAPI()
class Parent2:
def method(self):
return 'parent'
class Child2(Parent2):
def method(self): # Override
return 'child'
# What make_fastapi_ingress does:
endpoint_map = {'method': lambda app: app.get('/test')}
for method_name, route_factory in endpoint_map.items():
original_method = getattr(Child2, method_name) # MRO resolution
route_factory(app2)(original_method)
routes2 = [(r.path, r.endpoint.__qualname__) for r in app2.routes if isinstance(r, APIRoute)]
print(f'Routes: {routes2}')
print(f'Count: {len(routes2)}')
print('-> Only ONE registered, CHILD wins (MRO resolution)')Output: I think our behavior is more correct because it follows the class inheritance semantics. |
…y-project#59410) Signed-off-by: Kourosh Hakhamaneshi <kourosh@anyscale.com> Signed-off-by: kriyanshii <kriyanshishah06@gmail.com>
…y-project#59410) Signed-off-by: Kourosh Hakhamaneshi <kourosh@anyscale.com>
…y-project#59410) Signed-off-by: Kourosh Hakhamaneshi <kourosh@anyscale.com> Signed-off-by: peterxcli <peterxcli@gmail.com>
Summary
Fixed a bug in Ray Serve where inheriting from a class with FastAPI-decorated methods would cause FastAPI to treat
selfas a required query parameter, resulting in400 Bad Requesterrors:This affected any Serve user who inherits decorated methods, including Ray LLM's
OpenAiIngress.Root Cause
When a class inherits from another class with FastAPI-decorated methods, inherited methods retain their parent class's
__qualname__:In
make_fastapi_class_based_view, the check to identify class methods was:This fails for inherited methods because
"Child"is not in"Parent.my_method". As a result, these routes are not transformed, and FastAPI treatsselfas a required query parameter.Fix
Updated
make_fastapi_class_based_viewinray/serve/_private/http_util.pyto check the entire MRO (Method Resolution Order) instead of just the immediate class:This ensures inherited methods from any ancestor class are properly recognized and transformed.
Testing
Added
test_ingress_inheritancetoray/serve/tests/test_fastapi.pythat verifies:Both cases test that inherited endpoints work correctly via actual HTTP requests (not mock/unit tests).