Skip to content

[Serve] Fix make_fastapi_class_based_view for inherited methods#59410

Merged
kouroshHakha merged 6 commits intoray-project:masterfrom
kouroshHakha:kh/fix-make-fastapi-ingress-inheritance
Dec 15, 2025
Merged

[Serve] Fix make_fastapi_class_based_view for inherited methods#59410
kouroshHakha merged 6 commits intoray-project:masterfrom
kouroshHakha:kh/fix-make-fastapi-ingress-inheritance

Conversation

@kouroshHakha
Copy link
Copy Markdown
Contributor

@kouroshHakha kouroshHakha commented Dec 12, 2025

Summary

Fixed a bug in Ray Serve where inheriting from a class with FastAPI-decorated methods would cause FastAPI to treat self as a required query parameter, resulting in 400 Bad Request errors:

{"error":{"message":"Invalid request format: Field required at ('query', 'self')"}}

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__:

class Parent:
    @app.get("/endpoint")
    def my_method(self): ...

class Child(Parent):
    pass  # my_method.__qualname__ is still "Parent.my_method"

In make_fastapi_class_based_view, the check to identify class methods was:

cls.__qualname__ in route.endpoint.__qualname__

This fails for inherited methods because "Child" is not in "Parent.my_method". As a result, these routes are not transformed, and FastAPI treats self as a required query parameter.

Fix

Updated make_fastapi_class_based_view in ray/serve/_private/http_util.py to check the entire MRO (Method Resolution Order) instead of just the immediate class:

# Before (broken for inheritance):
cls.__qualname__ in route.endpoint.__qualname__

# After (handles inheritance correctly):
any(
    base.__qualname__ in route.endpoint.__qualname__
    for base in cls.__mro__
    if base is not object
)

This ensures inherited methods from any ancestor class are properly recognized and transformed.

Testing

Added test_ingress_inheritance to ray/serve/tests/test_fastapi.py that verifies:

  1. Multi-level inheritance: Grandparent → Parent → Child → ServedIngress
  2. Direct inheritance: BaseIngress → DirectServedIngress

Both cases test that inherited endpoints work correctly via actual HTTP requests (not mock/unit tests).

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>
@kouroshHakha kouroshHakha added the go add ONLY when ready to merge, run all tests label Dec 12, 2025
@kouroshHakha kouroshHakha marked this pull request as ready for review December 12, 2025 18:55
@kouroshHakha kouroshHakha requested a review from a team as a code owner December 12, 2025 18:55
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +45 to +48
assert "MyCustomIngress" in method.__qualname__, (
f"Method {method_name} should have MyCustomIngress in __qualname__, "
f"but got {method.__qualname__}"
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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__
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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__
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Similar to the previous comment, asserting the exact __qualname__ for this inherited method will make the test more robust.

Suggested change
assert "MyCustomIngress" in completions_method.__qualname__
assert completions_method.__qualname__ == f"{MyCustomIngress.__qualname__}.completions"

Comment on lines +146 to +149
assert "FinalIngress" in method.__qualname__, (
f"Method {method_name} should have FinalIngress in __qualname__, "
f"but got {method.__qualname__}"
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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__}'"
)

@ray-gardener ray-gardener bot added serve Ray Serve Related Issue llm labels Dec 12, 2025

# 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__
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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__.

Fix in Cursor Fix in Web

@eicherseiji
Copy link
Copy Markdown
Contributor

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?

@kouroshHakha
Copy link
Copy Markdown
Contributor Author

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.

Signed-off-by: Kourosh Hakhamaneshi <kourosh@anyscale.com>
@kouroshHakha kouroshHakha changed the title [Serve][LLM] Fix make_fastapi_ingress for inherited classes [Serve] Fix make_fastapi_class_based_view for inherited methods Dec 12, 2025
Signed-off-by: Kourosh Hakhamaneshi <kourosh@anyscale.com>
@kouroshHakha kouroshHakha requested a review from a team as a code owner December 12, 2025 21:58
@kouroshHakha
Copy link
Copy Markdown
Contributor Author

/gemini review

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +50 to +55
ingress_cls = make_fastapi_ingress(
MyCustomIngress, endpoint_map=custom_endpoints
)

# Verify the class was created
assert ingress_cls is not None
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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

Signed-off-by: Kourosh Hakhamaneshi <kourosh@anyscale.com>
Signed-off-by: Kourosh Hakhamaneshi <kourosh@anyscale.com>
Signed-off-by: Kourosh Hakhamaneshi <kourosh@anyscale.com>
route.endpoint.__qualname__.startswith(base.__qualname__ + ".")
for base in cls.__mro__
if base is not object
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

# (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)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are these type assignments still needed?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not strictly anymore, but I kept them here, because they keep the repr(cls) name nicer.

@harshit-anyscale
Copy link
Copy Markdown
Contributor

harshit-anyscale commented Dec 15, 2025

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?

Copy link
Copy Markdown
Contributor

@abrarsheikh abrarsheikh left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lg2m

@kouroshHakha
Copy link
Copy Markdown
Contributor Author

@harshit-anyscale

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:

=== NATIVE FASTAPI (decorators on both parent & child) ===
Routes: [('/test', 'Parent1.method'), ('/test', 'Child1.method')]
Count: 2
-> BOTH registered, PARENT wins (first match)

=== OUR CODE (make_fastapi_ingress approach) ===
Routes: [('/test', 'Child2.method')]
Count: 1
-> Only ONE registered, CHILD wins (MRO resolution)

I think our behavior is more correct because it follows the class inheritance semantics.

@kouroshHakha kouroshHakha enabled auto-merge (squash) December 15, 2025 17:33
@kouroshHakha kouroshHakha merged commit abdc820 into ray-project:master Dec 15, 2025
7 checks passed
kriyanshii pushed a commit to kriyanshii/ray that referenced this pull request Dec 16, 2025
…y-project#59410)

Signed-off-by: Kourosh Hakhamaneshi <kourosh@anyscale.com>
Signed-off-by: kriyanshii <kriyanshishah06@gmail.com>
Yicheng-Lu-llll pushed a commit to Yicheng-Lu-llll/ray that referenced this pull request Dec 22, 2025
…y-project#59410)

Signed-off-by: Kourosh Hakhamaneshi <kourosh@anyscale.com>
peterxcli pushed a commit to peterxcli/ray that referenced this pull request Feb 25, 2026
…y-project#59410)

Signed-off-by: Kourosh Hakhamaneshi <kourosh@anyscale.com>
Signed-off-by: peterxcli <peterxcli@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

go add ONLY when ready to merge, run all tests llm serve Ray Serve Related Issue

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants