Skip to content

Commit 9ea1d5c

Browse files
committed
feat: add ASGI server support for async functions
- Add uvicorn and uvicorn-worker to async optional dependencies - Refactor gunicorn.py with BaseGunicornApplication for shared config - Add UvicornApplication class for ASGI apps - Add StarletteApplication in asgi.py for development mode - Update HTTPServer to auto-detect Flask (WSGI) vs other (ASGI) apps - Add --gateway CLI flag to choose between wsgi and asgi - Update test_http.py to use Flask instance in tests
1 parent 49f6985 commit 9ea1d5c

File tree

6 files changed

+131
-30
lines changed

6 files changed

+131
-30
lines changed

pyproject.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,11 @@ dependencies = [
3636
Homepage = "https://github.com/googlecloudplatform/functions-framework-python"
3737

3838
[project.optional-dependencies]
39-
async = ["starlette>=0.37.0,<1.0.0; python_version>='3.8'"]
39+
async = [
40+
"starlette>=0.37.0,<1.0.0; python_version>='3.8'",
41+
"uvicorn>=0.18.0,<1.0.0; platform_system!='Windows' and python_version>='3.8'",
42+
"uvicorn-worker>=0.2.0,<1.0.0; platform_system!='Windows' and python_version>='3.8'"
43+
]
4044

4145
[project.scripts]
4246
ff = "functions_framework._cli:_cli"

src/functions_framework/_cli.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,18 @@
3232
@click.option("--host", envvar="HOST", type=click.STRING, default="0.0.0.0")
3333
@click.option("--port", envvar="PORT", type=click.INT, default=8080)
3434
@click.option("--debug", envvar="DEBUG", is_flag=True)
35-
def _cli(target, source, signature_type, host, port, debug):
36-
app = create_app(target, source, signature_type)
35+
@click.option(
36+
"--gateway",
37+
envvar="GATEWAY",
38+
type=click.Choice(["wsgi", "asgi"]),
39+
default="wsgi",
40+
help="Server gateway interface type (wsgi for sync, asgi for async)"
41+
)
42+
def _cli(target, source, signature_type, host, port, debug, gateway):
43+
if gateway == "asgi":
44+
from functions_framework.aio import create_asgi_app
45+
app = create_asgi_app(target, source, signature_type)
46+
else:
47+
app = create_app(target, source, signature_type)
48+
3749
create_server(app, debug).run(host, port)

src/functions_framework/_http/__init__.py

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
from flask import Flask
16+
1517
from functions_framework._http.flask import FlaskApplication
1618

1719

@@ -20,16 +22,30 @@ def __init__(self, app, debug, **options):
2022
self.app = app
2123
self.debug = debug
2224
self.options = options
23-
24-
if self.debug:
25-
self.server_class = FlaskApplication
26-
else:
27-
try:
28-
from functions_framework._http.gunicorn import GunicornApplication
29-
30-
self.server_class = GunicornApplication
31-
except ImportError as e:
25+
26+
# Check if app is Flask (WSGI) or not (ASGI)
27+
if isinstance(app, Flask):
28+
# WSGI app
29+
if self.debug:
3230
self.server_class = FlaskApplication
31+
else:
32+
try:
33+
from functions_framework._http.gunicorn import GunicornApplication
34+
self.server_class = GunicornApplication
35+
except ImportError as e:
36+
self.server_class = FlaskApplication
37+
else:
38+
# ASGI app (Starlette or other)
39+
if self.debug:
40+
from functions_framework._http.asgi import StarletteApplication
41+
self.server_class = StarletteApplication
42+
else:
43+
try:
44+
from functions_framework._http.gunicorn import UvicornApplication
45+
self.server_class = UvicornApplication
46+
except ImportError as e:
47+
from functions_framework._http.asgi import StarletteApplication
48+
self.server_class = StarletteApplication
3349

3450
def run(self, host, port):
3551
http_server = self.server_class(
@@ -38,5 +54,5 @@ def run(self, host, port):
3854
http_server.run()
3955

4056

41-
def create_server(wsgi_app, debug, **options):
42-
return HTTPServer(wsgi_app, debug, **options)
57+
def create_server(app, debug, **options):
58+
return HTTPServer(app, debug, **options)
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# Copyright 2025 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import uvicorn
16+
17+
18+
class StarletteApplication:
19+
"""A Starlette application that uses Uvicorn for direct serving (development mode)."""
20+
21+
def __init__(self, app, host, port, debug, **options):
22+
"""Initialize the Starlette application.
23+
24+
Args:
25+
app: The ASGI application to serve
26+
host: The host to bind to
27+
port: The port to bind to
28+
debug: Whether to run in debug mode
29+
**options: Additional options to pass to Uvicorn
30+
"""
31+
self.app = app
32+
self.host = host
33+
self.port = port
34+
self.debug = debug
35+
36+
# Default uvicorn config
37+
self.options = {
38+
"log_level": "debug" if debug else "error",
39+
"reload": debug,
40+
}
41+
self.options.update(options)
42+
43+
def run(self):
44+
"""Run the Uvicorn server directly."""
45+
uvicorn.run(
46+
self.app,
47+
host=self.host,
48+
port=int(self.port),
49+
**self.options
50+
)

src/functions_framework/_http/gunicorn.py

Lines changed: 32 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -27,32 +27,20 @@
2727
TIMEOUT_SECONDS = None
2828

2929

30-
class GunicornApplication(gunicorn.app.base.BaseApplication):
30+
class BaseGunicornApplication(gunicorn.app.base.BaseApplication):
31+
"""Base Gunicorn application with common configuration."""
3132
def __init__(self, app, host, port, debug, **options):
32-
threads = int(os.environ.get("THREADS", (os.cpu_count() or 1) * 4))
33-
3433
global TIMEOUT_SECONDS
3534
TIMEOUT_SECONDS = int(os.environ.get("CLOUD_RUN_TIMEOUT_SECONDS", 0))
3635

3736
self.options = {
3837
"bind": "%s:%s" % (host, port),
3938
"workers": int(os.environ.get("WORKERS", 1)),
40-
"threads": threads,
4139
"loglevel": os.environ.get("GUNICORN_LOG_LEVEL", "error"),
4240
"limit_request_line": 0,
41+
"timeout": TIMEOUT_SECONDS,
4342
}
4443

45-
if (
46-
TIMEOUT_SECONDS > 0
47-
and threads > 1
48-
and (os.environ.get("THREADED_TIMEOUT_ENABLED", "False").lower() == "true")
49-
): # pragma: no cover
50-
self.options["worker_class"] = (
51-
"functions_framework._http.gunicorn.GThreadWorkerWithTimeoutSupport"
52-
)
53-
else:
54-
self.options["timeout"] = TIMEOUT_SECONDS
55-
5644
self.options.update(options)
5745
self.app = app
5846

@@ -66,7 +54,36 @@ def load(self):
6654
return self.app
6755

6856

57+
class GunicornApplication(BaseGunicornApplication):
58+
"""Gunicorn application for WSGI apps with gthread worker support."""
59+
def __init__(self, app, host, port, debug, **options):
60+
threads = int(os.environ.get("THREADS", (os.cpu_count() or 1) * 4))
61+
options["threads"] = threads
62+
63+
super().__init__(app, host, port, debug, **options)
64+
65+
# Use custom worker with timeout support if conditions are met
66+
if (
67+
TIMEOUT_SECONDS > 0
68+
and threads > 1
69+
and (os.environ.get("THREADED_TIMEOUT_ENABLED", "False").lower() == "true")
70+
): # pragma: no cover
71+
self.options["worker_class"] = (
72+
"functions_framework._http.gunicorn.GThreadWorkerWithTimeoutSupport"
73+
)
74+
# Remove timeout from options when using custom worker
75+
del self.options["timeout"]
76+
77+
6978
class GThreadWorkerWithTimeoutSupport(ThreadWorker): # pragma: no cover
7079
def handle_request(self, req, conn):
7180
with ThreadingTimeout(TIMEOUT_SECONDS):
7281
super(GThreadWorkerWithTimeoutSupport, self).handle_request(req, conn)
82+
83+
84+
class UvicornApplication(BaseGunicornApplication):
85+
"""Gunicorn application for ASGI apps using Uvicorn workers."""
86+
def __init__(self, app, host, port, debug, **options):
87+
super().__init__(app, host, port, debug, **options)
88+
89+
self.options["worker_class"] = "uvicorn_worker.UvicornWorker"

tests/test_http.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import platform
1717
import sys
1818

19+
import flask
1920
import pretend
2021
import pytest
2122

@@ -45,7 +46,8 @@ def test_create_server(monkeypatch, debug):
4546
],
4647
)
4748
def test_httpserver(monkeypatch, debug, gunicorn_missing, expected):
48-
app = pretend.stub()
49+
# Create a mock Flask app
50+
app = flask.Flask("test")
4951
http_server = pretend.stub(run=pretend.call_recorder(lambda: None))
5052
server_classes = {
5153
"flask": pretend.call_recorder(lambda *a, **kw: http_server),

0 commit comments

Comments
 (0)