spec-first / connexion

Connexion is a modern Python web framework that makes spec-first and api-first development easy.
https://connexion.readthedocs.io/en/latest/
Apache License 2.0
4.45k stars 755 forks source link

Is Connexion 3 Compatible with Flask-Limiter? Recommendations for Flask rate-limiting with Connexion 3.0? #1942

Open Parthib opened 2 weeks ago

Parthib commented 2 weeks ago

Background

Flask-Limiter is a popular tool used to rate-limit endpoints of Flask applications.

We currently use it on our Flask server using connexion 2.14.2. However, due to the ASGI nature of Connexion 3.0, we are facing issues with the extension.

A basic use case of Flask-Limiter would be:

from flask import Flask

from flask_limiter import Limiter
from flask_limiter.util import get_remote_address

app = Flask(__name__)
limiter = Limiter(
    get_remote_address,
    app=app,
    default_limits=["200 per day", "50 per hour"],
    storage_uri="memory://",
)

@app.route("/slow")
@limiter.limit("1 per day")
def slow():
    return ":("

Internally, Flask-Limiter uses flask.request.endpoint to retrieve the key it should use to rate-limit for, but I don't think flask.request is really accessible in connexion 3.0. Whenever I attempt to, I get an exception stating

This typically means that you attempted to use functionality that needed
an active HTTP request. Consult the documentation on testing for
information about how to avoid this problem.

Attempted Solution

As I understand from reading the migration docs, connexion requests are now Starlette requests that can be retrieved via from connexion import request, so I attempted to take advantage of this. Flask-Limiter allows you define a callable in the Flask config RATELIMIT_REQUEST_IDENTIFIER that replaces the use of flask.Request.endpoint, so I tried the following:

  1. Create a Middleware that adds the endpoint to the request scope:
class RateLimitMiddleware(BaseHTTPMiddleware):

    async def dispatch(self, request: Request, call_next: RequestResponseEndpoint):
        request.scope["endpoint"] = request.url.path
        response = await call_next(request)
        return response

...

connex_app = connexion.FlaskApp(
    __name__, specification_dir="openapi/_build", swagger_ui_options=swagger_ui_options
)

connex_app.add_api(
    "openapi.yaml",
    resolver=connexion.resolver.RestyResolver("app.api"),
    pythonic_params=True,
    validate_responses=True,
    strict_validation=True,
    uri_parser_class=connexion_mods.OpenAPIURIParserWithDictLists,
)

connex_app.add_middleware(RateLimitMiddleware, position=MiddlewarePosition.BEFORE_EXCEPTION)
  1. Access the underlying Flask app to set the config:
app: flask.Flask = connex_app.app
app.config.from_object(config[config_name])
  1. In my config, set RATELIMIT_REQUEST_IDENTIFIER to a function that retrieves the endpoint from the scope of the Starlette request:
    
    from connexion import request

class BaseConfig: """Configures common variables and other settings for the backend.""" def get_endpoint(): return request.scope["endpoint"]

RATELIMIT_REQUEST_IDENTIFIER = get_endpoint

Unfortunately, this never seems to be within the scope of a connexion request as I still get the same Runtime exception:

RuntimeError: Working outside of application context.



# My Questions

1. Connexion for Flask used to be compatible with several other Flask libraries like Flask-Limiter which uses the underlying Flask config, but [utilizing the Flask config](https://github.com/spec-first/connexion/issues/1804) and other Flask context variables (flask.request, [flask.g](https://github.com/spec-first/connexion/issues/1880#issuecomment-1948858301)) no longer seems to be supported with Connexion 3.0. Is there an alternative way to use these extensions that I am overlooking?
2. For Flask-based applications using Connexion 3.0, what do you recommend for performing API rate-limiting? I don't see any solutions other than some in-house solution that takes advantage of a custom Middleware. I took a look at [slowapi](https://pypi.org/project/slowapi/) which has more of a focus on Starlette requests, but that doesn't seem compatible with Connexion either since it requires endpoints to take in a Starlette requests object.
whoseoyster commented 2 weeks ago

+1 on this

Parthib commented 2 weeks ago

Tried removing our rate limiting logic, and it looks to me that there is a bigger issue here:

Connexion's security handlers are now performed by middleware that exist outside of the Flask application, so it is not possible to access the Flask request context in the security handlers. Unfortunately for us, we have a dependency on Flask SQLAlchemy for our security handling that relies on access to the flask request context:

@decorators.setup_security_sentry_scope
def basic_auth(email: str, password: str, request):
    try:
        user: models.User = models.User.query.filter_by(email=email).one_or_none()
            ...

Stacktrace:

  File "/backend/lib/python3.9/site-packages/connexion/security.py", line 569, in verify_fn
    token_info = await token_info
  File "/backend/lib/python3.9/site-packages/connexion/security.py", line 116, in wrapper
    token_info = func(*args, **kwargs)
  File "/backend/app/api/decorators.py", line 59, in wrapper
    result = security_function(*args, **kwargs)
  File "/backend/app/api/connexion_auth.py", line 24, in basic_auth
    user: models.User = models.User.query.filter_by(email=email).one_or_none()
  File "/backend/lib/python3.9/site-packages/flask_sqlalchemy/model.py", line 23, in __get__
    cls, session=cls.__fsa__.session()  # type: ignore[arg-type]
  File "/backend/lib/python3.9/site-packages/sqlalchemy/orm/scoping.py", line 220, in __call__
    sess = self.registry()
  File "/backend/lib/python3.9/site-packages/sqlalchemy/util/_collections.py", line 632, in __call__
    key = self.scopefunc()
  File "/backend/lib/python3.9/site-packages/flask_sqlalchemy/session.py", line 111, in _app_ctx_id
    return id(app_ctx._get_current_object())  # type: ignore[attr-defined]
  File "/backend/lib/python3.9/site-packages/werkzeug/local.py", line 508, in _get_current_object
    raise RuntimeError(unbound_message) from None

There was a recent change to allow the security handling logic access to the ConnexionRequest request object, but that doesn't help us here because our dependency needs access to the flask application context.

@RobbeSneyders since you recently worked on passing the ConnexionRequest to the security handler - do you have any recommendations for our use case? Essentially we have dependencies in our security handling path that requires access to the flask application context, and this does not seem possible in Connexion 3.0