functions-framework 3.6.0__tar.gz → 3.8.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. {functions_framework-3.6.0 → functions_framework-3.8.0}/PKG-INFO +2 -1
  2. {functions_framework-3.6.0 → functions_framework-3.8.0}/setup.py +2 -1
  3. {functions_framework-3.6.0 → functions_framework-3.8.0}/src/functions_framework/__init__.py +39 -1
  4. functions_framework-3.8.0/src/functions_framework/_http/gunicorn.py +72 -0
  5. {functions_framework-3.6.0 → functions_framework-3.8.0}/src/functions_framework/exceptions.py +5 -1
  6. functions_framework-3.8.0/src/functions_framework/execution_id.py +156 -0
  7. functions_framework-3.8.0/src/functions_framework/request_timeout.py +42 -0
  8. {functions_framework-3.6.0 → functions_framework-3.8.0}/src/functions_framework.egg-info/PKG-INFO +2 -1
  9. {functions_framework-3.6.0 → functions_framework-3.8.0}/src/functions_framework.egg-info/SOURCES.txt +4 -0
  10. {functions_framework-3.6.0 → functions_framework-3.8.0}/src/functions_framework.egg-info/requires.txt +1 -0
  11. functions_framework-3.8.0/tests/test_execution_id.py +382 -0
  12. {functions_framework-3.6.0 → functions_framework-3.8.0}/tests/test_http.py +6 -6
  13. functions_framework-3.8.0/tests/test_timeouts.py +265 -0
  14. {functions_framework-3.6.0 → functions_framework-3.8.0}/tests/test_view_functions.py +1 -1
  15. functions_framework-3.6.0/src/functions_framework/_http/gunicorn.py +0 -40
  16. {functions_framework-3.6.0 → functions_framework-3.8.0}/LICENSE +0 -0
  17. {functions_framework-3.6.0 → functions_framework-3.8.0}/README.md +0 -0
  18. {functions_framework-3.6.0 → functions_framework-3.8.0}/setup.cfg +0 -0
  19. {functions_framework-3.6.0 → functions_framework-3.8.0}/src/functions_framework/__main__.py +0 -0
  20. {functions_framework-3.6.0 → functions_framework-3.8.0}/src/functions_framework/_cli.py +0 -0
  21. {functions_framework-3.6.0 → functions_framework-3.8.0}/src/functions_framework/_function_registry.py +0 -0
  22. {functions_framework-3.6.0 → functions_framework-3.8.0}/src/functions_framework/_http/__init__.py +0 -0
  23. {functions_framework-3.6.0 → functions_framework-3.8.0}/src/functions_framework/_http/flask.py +0 -0
  24. {functions_framework-3.6.0 → functions_framework-3.8.0}/src/functions_framework/_typed_event.py +0 -0
  25. {functions_framework-3.6.0 → functions_framework-3.8.0}/src/functions_framework/background_event.py +0 -0
  26. {functions_framework-3.6.0 → functions_framework-3.8.0}/src/functions_framework/event_conversion.py +0 -0
  27. {functions_framework-3.6.0 → functions_framework-3.8.0}/src/functions_framework/py.typed +0 -0
  28. {functions_framework-3.6.0 → functions_framework-3.8.0}/src/functions_framework.egg-info/dependency_links.txt +0 -0
  29. {functions_framework-3.6.0 → functions_framework-3.8.0}/src/functions_framework.egg-info/entry_points.txt +0 -0
  30. {functions_framework-3.6.0 → functions_framework-3.8.0}/src/functions_framework.egg-info/namespace_packages.txt +0 -0
  31. {functions_framework-3.6.0 → functions_framework-3.8.0}/src/functions_framework.egg-info/top_level.txt +0 -0
  32. {functions_framework-3.6.0 → functions_framework-3.8.0}/src/google/__init__.py +0 -0
  33. {functions_framework-3.6.0 → functions_framework-3.8.0}/src/google/cloud/__init__.py +0 -0
  34. {functions_framework-3.6.0 → functions_framework-3.8.0}/src/google/cloud/functions/__init__.py +0 -0
  35. {functions_framework-3.6.0 → functions_framework-3.8.0}/src/google/cloud/functions/context.py +0 -0
  36. {functions_framework-3.6.0 → functions_framework-3.8.0}/src/google/cloud/functions_v1/__init__.py +0 -0
  37. {functions_framework-3.6.0 → functions_framework-3.8.0}/src/google/cloud/functions_v1/context.py +0 -0
  38. {functions_framework-3.6.0 → functions_framework-3.8.0}/src/google/cloud/functions_v1beta2/__init__.py +0 -0
  39. {functions_framework-3.6.0 → functions_framework-3.8.0}/src/google/cloud/functions_v1beta2/context.py +0 -0
  40. {functions_framework-3.6.0 → functions_framework-3.8.0}/tests/test_cli.py +0 -0
  41. {functions_framework-3.6.0 → functions_framework-3.8.0}/tests/test_cloud_event_functions.py +0 -0
  42. {functions_framework-3.6.0 → functions_framework-3.8.0}/tests/test_convert.py +0 -0
  43. {functions_framework-3.6.0 → functions_framework-3.8.0}/tests/test_decorator_functions.py +0 -0
  44. {functions_framework-3.6.0 → functions_framework-3.8.0}/tests/test_function_registry.py +0 -0
  45. {functions_framework-3.6.0 → functions_framework-3.8.0}/tests/test_functions.py +0 -0
  46. {functions_framework-3.6.0 → functions_framework-3.8.0}/tests/test_main.py +0 -0
  47. {functions_framework-3.6.0 → functions_framework-3.8.0}/tests/test_samples.py +0 -0
  48. {functions_framework-3.6.0 → functions_framework-3.8.0}/tests/test_typed_event_functions.py +0 -0
  49. {functions_framework-3.6.0 → functions_framework-3.8.0}/tests/test_typing.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: functions-framework
3
- Version: 3.6.0
3
+ Version: 3.8.0
4
4
  Summary: An open source FaaS (Function as a service) framework for writing portable Python functions -- brought to you by the Google Cloud Functions team.
5
5
  Home-page: https://github.com/googlecloudplatform/functions-framework-python
6
6
  Author: Google LLC
@@ -23,6 +23,7 @@ Requires-Dist: click<9.0,>=7.0
23
23
  Requires-Dist: watchdog>=1.0.0
24
24
  Requires-Dist: gunicorn>=19.2.0; platform_system != "Windows"
25
25
  Requires-Dist: cloudevents<2.0.0,>=1.2.0
26
+ Requires-Dist: Werkzeug<4.0.0,>=0.14
26
27
 
27
28
  # Functions Framework for Python
28
29
 
@@ -25,7 +25,7 @@ with open(path.join(here, "README.md"), encoding="utf-8") as f:
25
25
 
26
26
  setup(
27
27
  name="functions-framework",
28
- version="3.6.0",
28
+ version="3.8.0",
29
29
  description="An open source FaaS (Function as a service) framework for writing portable Python functions -- brought to you by the Google Cloud Functions team.",
30
30
  long_description=long_description,
31
31
  long_description_content_type="text/markdown",
@@ -55,6 +55,7 @@ setup(
55
55
  "watchdog>=1.0.0",
56
56
  "gunicorn>=19.2.0; platform_system!='Windows'",
57
57
  "cloudevents>=1.2.0,<2.0.0",
58
+ "Werkzeug>=0.14,<4.0.0",
58
59
  ],
59
60
  entry_points={
60
61
  "console_scripts": [
@@ -17,6 +17,8 @@ import inspect
17
17
  import io
18
18
  import json
19
19
  import logging
20
+ import logging.config
21
+ import os
20
22
  import os.path
21
23
  import pathlib
22
24
  import sys
@@ -32,7 +34,12 @@ import werkzeug
32
34
  from cloudevents.http import from_http, is_binary
33
35
  from cloudevents.http.event import CloudEvent
34
36
 
35
- from functions_framework import _function_registry, _typed_event, event_conversion
37
+ from functions_framework import (
38
+ _function_registry,
39
+ _typed_event,
40
+ event_conversion,
41
+ execution_id,
42
+ )
36
43
  from functions_framework.background_event import BackgroundEvent
37
44
  from functions_framework.exceptions import (
38
45
  EventConversionException,
@@ -129,6 +136,7 @@ def setup_logging():
129
136
 
130
137
 
131
138
  def _http_view_func_wrapper(function, request):
139
+ @execution_id.set_execution_context(request, _enable_execution_id_logging())
132
140
  @functools.wraps(function)
133
141
  def view_func(path):
134
142
  return function(request._get_current_object())
@@ -143,6 +151,7 @@ def _run_cloud_event(function, request):
143
151
 
144
152
 
145
153
  def _typed_event_func_wrapper(function, request, inputType: Type):
154
+ @execution_id.set_execution_context(request, _enable_execution_id_logging())
146
155
  def view_func(path):
147
156
  try:
148
157
  data = request.get_json()
@@ -163,6 +172,7 @@ def _typed_event_func_wrapper(function, request, inputType: Type):
163
172
 
164
173
 
165
174
  def _cloud_event_view_func_wrapper(function, request):
175
+ @execution_id.set_execution_context(request, _enable_execution_id_logging())
166
176
  def view_func(path):
167
177
  ce_exception = None
168
178
  event = None
@@ -198,6 +208,7 @@ def _cloud_event_view_func_wrapper(function, request):
198
208
 
199
209
 
200
210
  def _event_view_func_wrapper(function, request):
211
+ @execution_id.set_execution_context(request, _enable_execution_id_logging())
201
212
  def view_func(path):
202
213
  if event_conversion.is_convertable_cloud_event(request):
203
214
  # Convert this CloudEvent to the equivalent background event data and context.
@@ -332,6 +343,9 @@ def create_app(target=None, source=None, signature_type=None):
332
343
 
333
344
  source_module, spec = _function_registry.load_function_module(source)
334
345
 
346
+ if _enable_execution_id_logging():
347
+ _configure_app_execution_id_logging()
348
+
335
349
  # Create the application
336
350
  _app = flask.Flask(target, template_folder=template_folder)
337
351
  _app.register_error_handler(500, crash_handler)
@@ -355,6 +369,7 @@ def create_app(target=None, source=None, signature_type=None):
355
369
  sys.stderr = _LoggingHandler("ERROR", sys.stderr)
356
370
  setup_logging()
357
371
 
372
+ _app.wsgi_app = execution_id.WsgiMiddleware(_app.wsgi_app)
358
373
  # Execute the module, within the application context
359
374
  with _app.app_context():
360
375
  try:
@@ -411,6 +426,29 @@ class LazyWSGIApp:
411
426
  return self.app(*args, **kwargs)
412
427
 
413
428
 
429
+ def _configure_app_execution_id_logging():
430
+ # Logging needs to be configured before app logger is accessed
431
+ logging.config.dictConfig(
432
+ {
433
+ "version": 1,
434
+ "handlers": {
435
+ "wsgi": {
436
+ "class": "logging.StreamHandler",
437
+ "stream": "ext://functions_framework.execution_id.logging_stream",
438
+ },
439
+ },
440
+ "root": {"level": "WARNING", "handlers": ["wsgi"]},
441
+ }
442
+ )
443
+
444
+
445
+ def _enable_execution_id_logging():
446
+ # Based on distutils.util.strtobool
447
+ truthy_values = ("y", "yes", "t", "true", "on", "1")
448
+ env_var_value = os.environ.get("LOG_EXECUTION_ID")
449
+ return env_var_value in truthy_values
450
+
451
+
414
452
  app = LazyWSGIApp()
415
453
 
416
454
 
@@ -0,0 +1,72 @@
1
+ # Copyright 2024 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 os
16
+
17
+ import gunicorn.app.base
18
+
19
+ from gunicorn.workers.gthread import ThreadWorker
20
+
21
+ from ..request_timeout import ThreadingTimeout
22
+
23
+ # global for use in our custom gthread worker; the gunicorn arbiter spawns these
24
+ # and it's not possible to inject (and self.timeout means something different to
25
+ # async workers!)
26
+ # set/managed in gunicorn application init for test-friendliness
27
+ TIMEOUT_SECONDS = None
28
+
29
+
30
+ class GunicornApplication(gunicorn.app.base.BaseApplication):
31
+ def __init__(self, app, host, port, debug, **options):
32
+ threads = int(os.environ.get("THREADS", (os.cpu_count() or 1) * 4))
33
+
34
+ global TIMEOUT_SECONDS
35
+ TIMEOUT_SECONDS = int(os.environ.get("CLOUD_RUN_TIMEOUT_SECONDS", 0))
36
+
37
+ self.options = {
38
+ "bind": "%s:%s" % (host, port),
39
+ "workers": int(os.environ.get("WORKERS", 1)),
40
+ "threads": threads,
41
+ "loglevel": os.environ.get("GUNICORN_LOG_LEVEL", "error"),
42
+ "limit_request_line": 0,
43
+ }
44
+
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
+
56
+ self.options.update(options)
57
+ self.app = app
58
+
59
+ super().__init__()
60
+
61
+ def load_config(self):
62
+ for key, value in self.options.items():
63
+ self.cfg.set(key, value)
64
+
65
+ def load(self):
66
+ return self.app
67
+
68
+
69
+ class GThreadWorkerWithTimeoutSupport(ThreadWorker): # pragma: no cover
70
+ def handle_request(self, req, conn):
71
+ with ThreadingTimeout(TIMEOUT_SECONDS):
72
+ super(GThreadWorkerWithTimeoutSupport, self).handle_request(req, conn)
@@ -1,4 +1,4 @@
1
- # Copyright 2020 Google LLC
1
+ # Copyright 2024 Google LLC
2
2
  #
3
3
  # Licensed under the Apache License, Version 2.0 (the "License");
4
4
  # you may not use this file except in compliance with the License.
@@ -35,3 +35,7 @@ class MissingTargetException(FunctionsFrameworkException):
35
35
 
36
36
  class EventConversionException(FunctionsFrameworkException):
37
37
  pass
38
+
39
+
40
+ class RequestTimeoutException(FunctionsFrameworkException):
41
+ pass
@@ -0,0 +1,156 @@
1
+ # Copyright 2020 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 contextlib
16
+ import functools
17
+ import io
18
+ import json
19
+ import logging
20
+ import random
21
+ import re
22
+ import string
23
+ import sys
24
+
25
+ import flask
26
+
27
+ from werkzeug.local import LocalProxy
28
+
29
+ _EXECUTION_ID_LENGTH = 12
30
+ _EXECUTION_ID_CHARSET = string.digits + string.ascii_letters
31
+ _LOGGING_API_LABELS_FIELD = "logging.googleapis.com/labels"
32
+ _LOGGING_API_SPAN_ID_FIELD = "logging.googleapis.com/spanId"
33
+ _TRACE_CONTEXT_REGEX_PATTERN = re.compile(
34
+ r"^(?P<trace_id>[\w\d]+)/(?P<span_id>\d+);o=(?P<options>[01])$"
35
+ )
36
+ EXECUTION_ID_REQUEST_HEADER = "Function-Execution-Id"
37
+ TRACE_CONTEXT_REQUEST_HEADER = "X-Cloud-Trace-Context"
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+
42
+ class ExecutionContext:
43
+ def __init__(self, execution_id=None, span_id=None):
44
+ self.execution_id = execution_id
45
+ self.span_id = span_id
46
+
47
+
48
+ def _get_current_context():
49
+ return (
50
+ flask.g.execution_id_context
51
+ if flask.has_request_context() and "execution_id_context" in flask.g
52
+ else None
53
+ )
54
+
55
+
56
+ def _set_current_context(context):
57
+ if flask.has_request_context():
58
+ flask.g.execution_id_context = context
59
+
60
+
61
+ def _generate_execution_id():
62
+ return "".join(
63
+ _EXECUTION_ID_CHARSET[random.randrange(len(_EXECUTION_ID_CHARSET))]
64
+ for _ in range(_EXECUTION_ID_LENGTH)
65
+ )
66
+
67
+
68
+ # Middleware to add execution id to request header if one does not already exist
69
+ class WsgiMiddleware:
70
+ def __init__(self, wsgi_app):
71
+ self.wsgi_app = wsgi_app
72
+
73
+ def __call__(self, environ, start_response):
74
+ execution_id = (
75
+ environ.get("HTTP_FUNCTION_EXECUTION_ID") or _generate_execution_id()
76
+ )
77
+ environ["HTTP_FUNCTION_EXECUTION_ID"] = execution_id
78
+ return self.wsgi_app(environ, start_response)
79
+
80
+
81
+ # Sets execution id and span id for the request
82
+ def set_execution_context(request, enable_id_logging=False):
83
+ if enable_id_logging:
84
+ stdout_redirect = contextlib.redirect_stdout(
85
+ LoggingHandlerAddExecutionId(sys.stdout)
86
+ )
87
+ stderr_redirect = contextlib.redirect_stderr(
88
+ LoggingHandlerAddExecutionId(sys.stderr)
89
+ )
90
+ else:
91
+ stdout_redirect = contextlib.nullcontext()
92
+ stderr_redirect = contextlib.nullcontext()
93
+
94
+ def decorator(view_function):
95
+ @functools.wraps(view_function)
96
+ def wrapper(*args, **kwargs):
97
+ trace_context = re.match(
98
+ _TRACE_CONTEXT_REGEX_PATTERN,
99
+ request.headers.get(TRACE_CONTEXT_REQUEST_HEADER, ""),
100
+ )
101
+ execution_id = request.headers.get(EXECUTION_ID_REQUEST_HEADER)
102
+ span_id = trace_context.group("span_id") if trace_context else None
103
+ _set_current_context(ExecutionContext(execution_id, span_id))
104
+
105
+ with stderr_redirect, stdout_redirect:
106
+ return view_function(*args, **kwargs)
107
+
108
+ return wrapper
109
+
110
+ return decorator
111
+
112
+
113
+ @LocalProxy
114
+ def logging_stream():
115
+ return LoggingHandlerAddExecutionId(stream=flask.logging.wsgi_errors_stream)
116
+
117
+
118
+ class LoggingHandlerAddExecutionId(io.TextIOWrapper):
119
+ def __new__(cls, stream=sys.stdout):
120
+ if isinstance(stream, LoggingHandlerAddExecutionId):
121
+ return stream
122
+ else:
123
+ return super(LoggingHandlerAddExecutionId, cls).__new__(cls)
124
+
125
+ def __init__(self, stream=sys.stdout):
126
+ io.TextIOWrapper.__init__(self, io.StringIO())
127
+ self.stream = stream
128
+
129
+ def write(self, contents):
130
+ if contents == "\n":
131
+ return
132
+ current_context = _get_current_context()
133
+ if current_context is None:
134
+ self.stream.write(contents + "\n")
135
+ self.stream.flush()
136
+ return
137
+ try:
138
+ execution_id = current_context.execution_id
139
+ span_id = current_context.span_id
140
+ payload = json.loads(contents)
141
+ if not isinstance(payload, dict):
142
+ payload = {"message": contents}
143
+ except json.JSONDecodeError:
144
+ if len(contents) > 0 and contents[-1] == "\n":
145
+ contents = contents[:-1]
146
+ payload = {"message": contents}
147
+ if execution_id:
148
+ payload[_LOGGING_API_LABELS_FIELD] = payload.get(
149
+ _LOGGING_API_LABELS_FIELD, {}
150
+ )
151
+ payload[_LOGGING_API_LABELS_FIELD]["execution_id"] = execution_id
152
+ if span_id:
153
+ payload[_LOGGING_API_SPAN_ID_FIELD] = span_id
154
+ self.stream.write(json.dumps(payload))
155
+ self.stream.write("\n")
156
+ self.stream.flush()
@@ -0,0 +1,42 @@
1
+ import ctypes
2
+ import logging
3
+ import threading
4
+
5
+ from .exceptions import RequestTimeoutException
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ class ThreadingTimeout(object): # pragma: no cover
11
+ def __init__(self, seconds):
12
+ self.seconds = seconds
13
+ self.target_tid = threading.current_thread().ident
14
+ self.timer = None
15
+
16
+ def __enter__(self):
17
+ self.timer = threading.Timer(self.seconds, self._raise_exc)
18
+ self.timer.start()
19
+ return self
20
+
21
+ def __exit__(self, exc_type, exc_val, exc_tb):
22
+ self.timer.cancel()
23
+ if exc_type is RequestTimeoutException:
24
+ logger.warning(
25
+ "Request handling exceeded {0} seconds timeout; terminating request handling...".format(
26
+ self.seconds
27
+ ),
28
+ exc_info=(exc_type, exc_val, exc_tb),
29
+ )
30
+ return False
31
+
32
+ def _raise_exc(self):
33
+ ret = ctypes.pythonapi.PyThreadState_SetAsyncExc(
34
+ ctypes.c_long(self.target_tid), ctypes.py_object(RequestTimeoutException)
35
+ )
36
+ if ret == 0:
37
+ raise ValueError("Invalid thread ID {}".format(self.target_tid))
38
+ elif ret > 1:
39
+ ctypes.pythonapi.PyThreadState_SetAsyncExc(
40
+ ctypes.c_long(self.target_tid), None
41
+ )
42
+ raise SystemError("PyThreadState_SetAsyncExc failed")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: functions-framework
3
- Version: 3.6.0
3
+ Version: 3.8.0
4
4
  Summary: An open source FaaS (Function as a service) framework for writing portable Python functions -- brought to you by the Google Cloud Functions team.
5
5
  Home-page: https://github.com/googlecloudplatform/functions-framework-python
6
6
  Author: Google LLC
@@ -23,6 +23,7 @@ Requires-Dist: click<9.0,>=7.0
23
23
  Requires-Dist: watchdog>=1.0.0
24
24
  Requires-Dist: gunicorn>=19.2.0; platform_system != "Windows"
25
25
  Requires-Dist: cloudevents<2.0.0,>=1.2.0
26
+ Requires-Dist: Werkzeug<4.0.0,>=0.14
26
27
 
27
28
  # Functions Framework for Python
28
29
 
@@ -10,7 +10,9 @@ src/functions_framework/_typed_event.py
10
10
  src/functions_framework/background_event.py
11
11
  src/functions_framework/event_conversion.py
12
12
  src/functions_framework/exceptions.py
13
+ src/functions_framework/execution_id.py
13
14
  src/functions_framework/py.typed
15
+ src/functions_framework/request_timeout.py
14
16
  src/functions_framework.egg-info/PKG-INFO
15
17
  src/functions_framework.egg-info/SOURCES.txt
16
18
  src/functions_framework.egg-info/dependency_links.txt
@@ -33,11 +35,13 @@ tests/test_cli.py
33
35
  tests/test_cloud_event_functions.py
34
36
  tests/test_convert.py
35
37
  tests/test_decorator_functions.py
38
+ tests/test_execution_id.py
36
39
  tests/test_function_registry.py
37
40
  tests/test_functions.py
38
41
  tests/test_http.py
39
42
  tests/test_main.py
40
43
  tests/test_samples.py
44
+ tests/test_timeouts.py
41
45
  tests/test_typed_event_functions.py
42
46
  tests/test_typing.py
43
47
  tests/test_view_functions.py
@@ -2,6 +2,7 @@ flask<4.0,>=1.0
2
2
  click<9.0,>=7.0
3
3
  watchdog>=1.0.0
4
4
  cloudevents<2.0.0,>=1.2.0
5
+ Werkzeug<4.0.0,>=0.14
5
6
 
6
7
  [:platform_system != "Windows"]
7
8
  gunicorn>=19.2.0
@@ -0,0 +1,382 @@
1
+ # Copyright 2024 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
+ import asyncio
15
+ import json
16
+ import pathlib
17
+ import re
18
+ import sys
19
+
20
+ from functools import partial
21
+ from unittest.mock import Mock
22
+
23
+ import pretend
24
+ import pytest
25
+
26
+ from functions_framework import create_app, execution_id
27
+
28
+ TEST_FUNCTIONS_DIR = pathlib.Path(__file__).resolve().parent / "test_functions"
29
+ TEST_EXECUTION_ID = "test_execution_id"
30
+ TEST_SPAN_ID = "123456"
31
+
32
+
33
+ def test_user_function_can_retrieve_execution_id_from_header():
34
+ source = TEST_FUNCTIONS_DIR / "execution_id" / "main.py"
35
+ target = "function"
36
+ client = create_app(target, source).test_client()
37
+ resp = client.post(
38
+ "/",
39
+ headers={
40
+ "Function-Execution-Id": TEST_EXECUTION_ID,
41
+ "Content-Type": "application/json",
42
+ },
43
+ )
44
+
45
+ assert resp.get_json()["execution_id"] == TEST_EXECUTION_ID
46
+
47
+
48
+ def test_uncaught_exception_in_user_function_sets_execution_id(capsys, monkeypatch):
49
+ monkeypatch.setenv("LOG_EXECUTION_ID", "true")
50
+ source = TEST_FUNCTIONS_DIR / "execution_id" / "main.py"
51
+ target = "error"
52
+ app = create_app(target, source)
53
+ client = app.test_client()
54
+ resp = client.post(
55
+ "/",
56
+ headers={
57
+ "Function-Execution-Id": TEST_EXECUTION_ID,
58
+ "Content-Type": "application/json",
59
+ },
60
+ )
61
+ assert resp.status_code == 500
62
+ record = capsys.readouterr()
63
+ assert f'"execution_id": "{TEST_EXECUTION_ID}"' in record.err
64
+
65
+
66
+ def test_print_from_user_function_sets_execution_id(capsys, monkeypatch):
67
+ monkeypatch.setenv("LOG_EXECUTION_ID", "true")
68
+ source = TEST_FUNCTIONS_DIR / "execution_id" / "main.py"
69
+ target = "print_message"
70
+ app = create_app(target, source)
71
+ client = app.test_client()
72
+ client.post(
73
+ "/",
74
+ headers={
75
+ "Function-Execution-Id": TEST_EXECUTION_ID,
76
+ "Content-Type": "application/json",
77
+ },
78
+ json={"message": "some-message"},
79
+ )
80
+ record = capsys.readouterr()
81
+ assert f'"execution_id": "{TEST_EXECUTION_ID}"' in record.out
82
+ assert '"message": "some-message"' in record.out
83
+
84
+
85
+ def test_log_from_user_function_sets_execution_id(capsys, monkeypatch):
86
+ monkeypatch.setenv("LOG_EXECUTION_ID", "true")
87
+ source = TEST_FUNCTIONS_DIR / "execution_id" / "main.py"
88
+ target = "log_message"
89
+ app = create_app(target, source)
90
+ client = app.test_client()
91
+ client.post(
92
+ "/",
93
+ headers={
94
+ "Function-Execution-Id": TEST_EXECUTION_ID,
95
+ "Content-Type": "application/json",
96
+ },
97
+ json={"message": json.dumps({"custom-field": "some-message"})},
98
+ )
99
+ record = capsys.readouterr()
100
+ assert f'"execution_id": "{TEST_EXECUTION_ID}"' in record.err
101
+ assert '"custom-field": "some-message"' in record.err
102
+
103
+
104
+ def test_user_function_can_retrieve_generated_execution_id(monkeypatch):
105
+ monkeypatch.setattr(
106
+ execution_id, "_generate_execution_id", lambda: TEST_EXECUTION_ID
107
+ )
108
+ source = TEST_FUNCTIONS_DIR / "execution_id" / "main.py"
109
+ target = "function"
110
+ client = create_app(target, source).test_client()
111
+ resp = client.post(
112
+ "/",
113
+ headers={
114
+ "Content-Type": "application/json",
115
+ },
116
+ )
117
+
118
+ assert resp.get_json()["execution_id"] == TEST_EXECUTION_ID
119
+
120
+
121
+ def test_does_not_set_execution_id_when_not_enabled(capsys):
122
+ source = TEST_FUNCTIONS_DIR / "execution_id" / "main.py"
123
+ target = "print_message"
124
+ app = create_app(target, source)
125
+ client = app.test_client()
126
+ client.post(
127
+ "/",
128
+ headers={
129
+ "Function-Execution-Id": TEST_EXECUTION_ID,
130
+ "Content-Type": "application/json",
131
+ },
132
+ json={"message": "some-message"},
133
+ )
134
+ record = capsys.readouterr()
135
+ assert f'"execution_id": "{TEST_EXECUTION_ID}"' not in record.out
136
+ assert "some-message" in record.out
137
+
138
+
139
+ def test_does_not_set_execution_id_when_env_var_is_false(capsys, monkeypatch):
140
+ monkeypatch.setenv("LOG_EXECUTION_ID", "false")
141
+ source = TEST_FUNCTIONS_DIR / "execution_id" / "main.py"
142
+ target = "print_message"
143
+ app = create_app(target, source)
144
+ client = app.test_client()
145
+ client.post(
146
+ "/",
147
+ headers={
148
+ "Function-Execution-Id": TEST_EXECUTION_ID,
149
+ "Content-Type": "application/json",
150
+ },
151
+ json={"message": "some-message"},
152
+ )
153
+ record = capsys.readouterr()
154
+ assert f'"execution_id": "{TEST_EXECUTION_ID}"' not in record.out
155
+ assert "some-message" in record.out
156
+
157
+
158
+ def test_does_not_set_execution_id_when_env_var_is_not_bool_like(capsys, monkeypatch):
159
+ monkeypatch.setenv("LOG_EXECUTION_ID", "maybe")
160
+ source = TEST_FUNCTIONS_DIR / "execution_id" / "main.py"
161
+ target = "print_message"
162
+ app = create_app(target, source)
163
+ client = app.test_client()
164
+ client.post(
165
+ "/",
166
+ headers={
167
+ "Function-Execution-Id": TEST_EXECUTION_ID,
168
+ "Content-Type": "application/json",
169
+ },
170
+ json={"message": "some-message"},
171
+ )
172
+ record = capsys.readouterr()
173
+ assert f'"execution_id": "{TEST_EXECUTION_ID}"' not in record.out
174
+ assert "some-message" in record.out
175
+
176
+
177
+ def test_generate_execution_id():
178
+ expected_matching_regex = "^[0-9a-zA-Z]{12}$"
179
+ actual_execution_id = execution_id._generate_execution_id()
180
+
181
+ match = re.match(expected_matching_regex, actual_execution_id).group(0)
182
+ assert match == actual_execution_id
183
+
184
+
185
+ @pytest.mark.parametrize(
186
+ "headers,expected_execution_id,expected_span_id",
187
+ [
188
+ (
189
+ {
190
+ "X-Cloud-Trace-Context": f"TRACE_ID/{TEST_SPAN_ID};o=1",
191
+ "Function-Execution-Id": TEST_EXECUTION_ID,
192
+ },
193
+ TEST_EXECUTION_ID,
194
+ TEST_SPAN_ID,
195
+ ),
196
+ (
197
+ {
198
+ "X-Cloud-Trace-Context": f"TRACE_ID/{TEST_SPAN_ID};o=1",
199
+ "Function-Execution-Id": TEST_EXECUTION_ID,
200
+ },
201
+ TEST_EXECUTION_ID,
202
+ TEST_SPAN_ID,
203
+ ),
204
+ ({}, None, None),
205
+ (
206
+ {
207
+ "X-Cloud-Trace-Context": "malformed trace context string",
208
+ "Function-Execution-Id": TEST_EXECUTION_ID,
209
+ },
210
+ TEST_EXECUTION_ID,
211
+ None,
212
+ ),
213
+ ],
214
+ )
215
+ def test_set_execution_context(
216
+ headers, expected_execution_id, expected_span_id, monkeypatch
217
+ ):
218
+ request = pretend.stub(headers=headers)
219
+
220
+ def view_func():
221
+ pass
222
+
223
+ monkeypatch.setattr(
224
+ execution_id, "_generate_execution_id", lambda: TEST_EXECUTION_ID
225
+ )
226
+ mock_g = Mock()
227
+ monkeypatch.setattr(execution_id.flask, "g", mock_g)
228
+ monkeypatch.setattr(execution_id.flask, "has_request_context", lambda: True)
229
+ execution_id.set_execution_context(request)(view_func)()
230
+
231
+ assert mock_g.execution_id_context.span_id == expected_span_id
232
+ assert mock_g.execution_id_context.execution_id == expected_execution_id
233
+
234
+
235
+ @pytest.mark.parametrize(
236
+ "log_message,expected_log_json",
237
+ [
238
+ ("text message", {"message": "text message"}),
239
+ (
240
+ json.dumps({"custom-field1": "value1", "custom-field2": "value2"}),
241
+ {"custom-field1": "value1", "custom-field2": "value2"},
242
+ ),
243
+ ("[]", {"message": "[]"}),
244
+ ],
245
+ )
246
+ def test_log_handler(monkeypatch, log_message, expected_log_json, capsys):
247
+ log_handler = execution_id.LoggingHandlerAddExecutionId(stream=sys.stdout)
248
+ monkeypatch.setattr(
249
+ execution_id,
250
+ "_get_current_context",
251
+ lambda: execution_id.ExecutionContext(
252
+ span_id=TEST_SPAN_ID, execution_id=TEST_EXECUTION_ID
253
+ ),
254
+ )
255
+ expected_log_json.update(
256
+ {
257
+ "logging.googleapis.com/labels": {
258
+ "execution_id": TEST_EXECUTION_ID,
259
+ },
260
+ "logging.googleapis.com/spanId": TEST_SPAN_ID,
261
+ }
262
+ )
263
+
264
+ log_handler.write(log_message)
265
+ record = capsys.readouterr()
266
+ assert json.loads(record.out) == expected_log_json
267
+ assert json.loads(record.out) == expected_log_json
268
+
269
+
270
+ def test_log_handler_without_context_logs_unmodified(monkeypatch, capsys):
271
+ log_handler = execution_id.LoggingHandlerAddExecutionId(stream=sys.stdout)
272
+ monkeypatch.setattr(
273
+ execution_id,
274
+ "_get_current_context",
275
+ lambda: None,
276
+ )
277
+ expected_message = "log message\n"
278
+
279
+ log_handler.write("log message")
280
+ record = capsys.readouterr()
281
+ assert record.out == expected_message
282
+
283
+
284
+ def test_log_handler_ignores_newlines(monkeypatch, capsys):
285
+ log_handler = execution_id.LoggingHandlerAddExecutionId(stream=sys.stdout)
286
+ monkeypatch.setattr(
287
+ execution_id,
288
+ "_get_current_context",
289
+ lambda: execution_id.ExecutionContext(
290
+ span_id=TEST_SPAN_ID, execution_id=TEST_EXECUTION_ID
291
+ ),
292
+ )
293
+
294
+ log_handler.write("\n")
295
+ record = capsys.readouterr()
296
+ assert record.out == ""
297
+
298
+
299
+ def test_log_handler_does_not_nest():
300
+ log_handler_1 = execution_id.LoggingHandlerAddExecutionId(stream=sys.stdout)
301
+ log_handler_2 = execution_id.LoggingHandlerAddExecutionId(log_handler_1)
302
+
303
+ assert log_handler_1 == log_handler_2
304
+
305
+
306
+ def test_log_handler_omits_empty_execution_context(monkeypatch, capsys):
307
+ log_handler = execution_id.LoggingHandlerAddExecutionId(stream=sys.stdout)
308
+ monkeypatch.setattr(
309
+ execution_id,
310
+ "_get_current_context",
311
+ lambda: execution_id.ExecutionContext(span_id=None, execution_id=None),
312
+ )
313
+ expected_json = {
314
+ "message": "some message",
315
+ }
316
+
317
+ log_handler.write("some message")
318
+ record = capsys.readouterr()
319
+ assert json.loads(record.out) == expected_json
320
+
321
+
322
+ @pytest.mark.asyncio
323
+ async def test_maintains_execution_id_for_concurrent_requests(monkeypatch, capsys):
324
+ monkeypatch.setenv("LOG_EXECUTION_ID", "true")
325
+ monkeypatch.setattr(
326
+ execution_id,
327
+ "_generate_execution_id",
328
+ Mock(side_effect=("test-execution-id-1", "test-execution-id-2")),
329
+ )
330
+
331
+ expected_logs = (
332
+ {
333
+ "message": "message1",
334
+ "logging.googleapis.com/labels": {"execution_id": "test-execution-id-1"},
335
+ },
336
+ {
337
+ "message": "message2",
338
+ "logging.googleapis.com/labels": {"execution_id": "test-execution-id-2"},
339
+ },
340
+ {
341
+ "message": "message1",
342
+ "logging.googleapis.com/labels": {"execution_id": "test-execution-id-1"},
343
+ },
344
+ {
345
+ "message": "message2",
346
+ "logging.googleapis.com/labels": {"execution_id": "test-execution-id-2"},
347
+ },
348
+ )
349
+
350
+ source = TEST_FUNCTIONS_DIR / "execution_id" / "main.py"
351
+ target = "sleep"
352
+ client = create_app(target, source).test_client()
353
+ loop = asyncio.get_event_loop()
354
+ response1 = loop.run_in_executor(
355
+ None,
356
+ partial(
357
+ client.post,
358
+ "/",
359
+ headers={
360
+ "Content-Type": "application/json",
361
+ },
362
+ json={"message": "message1"},
363
+ ),
364
+ )
365
+ response2 = loop.run_in_executor(
366
+ None,
367
+ partial(
368
+ client.post,
369
+ "/",
370
+ headers={
371
+ "Content-Type": "application/json",
372
+ },
373
+ json={"message": "message2"},
374
+ ),
375
+ )
376
+ await asyncio.wait((response1, response2))
377
+ record = capsys.readouterr()
378
+ logs = record.err.strip().split("\n")
379
+ logs_as_json = tuple(json.loads(log) for log in logs)
380
+
381
+ sort_key = lambda d: d["message"]
382
+ assert sorted(logs_as_json, key=sort_key) == sorted(expected_logs, key=sort_key)
@@ -97,17 +97,17 @@ def test_gunicorn_application(debug):
97
97
  assert gunicorn_app.app == app
98
98
  assert gunicorn_app.options == {
99
99
  "bind": "%s:%s" % (host, port),
100
- "workers": os.cpu_count() * 4,
101
- "threads": 1,
102
- "timeout": 300,
100
+ "workers": 1,
101
+ "threads": os.cpu_count() * 4,
102
+ "timeout": 0,
103
103
  "loglevel": "error",
104
104
  "limit_request_line": 0,
105
105
  }
106
106
 
107
107
  assert gunicorn_app.cfg.bind == ["1.2.3.4:1234"]
108
- assert gunicorn_app.cfg.workers == os.cpu_count() * 4
109
- assert gunicorn_app.cfg.threads == 1
110
- assert gunicorn_app.cfg.timeout == 300
108
+ assert gunicorn_app.cfg.workers == 1
109
+ assert gunicorn_app.cfg.threads == os.cpu_count() * 4
110
+ assert gunicorn_app.cfg.timeout == 0
111
111
  assert gunicorn_app.load() == app
112
112
 
113
113
 
@@ -0,0 +1,265 @@
1
+ # Copyright 2024 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
+ import pathlib
15
+ import socket
16
+ import time
17
+
18
+ from multiprocessing import Process
19
+
20
+ import pytest
21
+ import requests
22
+
23
+ ff_gunicorn = pytest.importorskip("functions_framework._http.gunicorn")
24
+
25
+
26
+ from functions_framework import create_app
27
+
28
+ TEST_FUNCTIONS_DIR = pathlib.Path(__file__).resolve().parent / "test_functions"
29
+ TEST_HOST = "0.0.0.0"
30
+ TEST_PORT = "8080"
31
+
32
+
33
+ @pytest.fixture(autouse=True)
34
+ def run_around_tests():
35
+ # the test samples test also listens on 8080, so let's be good stewards of
36
+ # the port and make sure it's free
37
+ _wait_for_no_listen(TEST_HOST, TEST_PORT)
38
+ yield
39
+ _wait_for_no_listen(TEST_HOST, TEST_PORT)
40
+
41
+
42
+ @pytest.mark.skipif("platform.system() == 'Windows'")
43
+ @pytest.mark.skipif("platform.system() == 'Darwin'")
44
+ @pytest.mark.slow_integration_test
45
+ def test_no_timeout_allows_request_processing_to_finish():
46
+ source = TEST_FUNCTIONS_DIR / "timeout" / "main.py"
47
+ target = "function"
48
+
49
+ app = create_app(target, source)
50
+
51
+ options = {}
52
+
53
+ gunicorn_app = ff_gunicorn.GunicornApplication(
54
+ app, TEST_HOST, TEST_PORT, False, **options
55
+ )
56
+
57
+ gunicorn_p = Process(target=gunicorn_app.run)
58
+ gunicorn_p.start()
59
+
60
+ _wait_for_listen(TEST_HOST, TEST_PORT)
61
+
62
+ result = requests.get("http://{}:{}/".format(TEST_HOST, TEST_PORT))
63
+
64
+ gunicorn_p.terminate()
65
+ gunicorn_p.join()
66
+
67
+ assert result.status_code == 200
68
+
69
+
70
+ @pytest.mark.skipif("platform.system() == 'Windows'")
71
+ @pytest.mark.skipif("platform.system() == 'Darwin'")
72
+ @pytest.mark.slow_integration_test
73
+ def test_timeout_but_not_threaded_timeout_enabled_does_not_kill(monkeypatch):
74
+ monkeypatch.setenv("CLOUD_RUN_TIMEOUT_SECONDS", "1")
75
+ monkeypatch.setenv("THREADED_TIMEOUT_ENABLED", "false")
76
+ source = TEST_FUNCTIONS_DIR / "timeout" / "main.py"
77
+ target = "function"
78
+
79
+ app = create_app(target, source)
80
+
81
+ options = {}
82
+
83
+ gunicorn_app = ff_gunicorn.GunicornApplication(
84
+ app, TEST_HOST, TEST_PORT, False, **options
85
+ )
86
+
87
+ gunicorn_p = Process(target=gunicorn_app.run)
88
+ gunicorn_p.start()
89
+
90
+ _wait_for_listen(TEST_HOST, TEST_PORT)
91
+
92
+ result = requests.get("http://{}:{}/".format(TEST_HOST, TEST_PORT))
93
+
94
+ gunicorn_p.terminate()
95
+ gunicorn_p.join()
96
+
97
+ assert result.status_code == 200
98
+
99
+
100
+ @pytest.mark.skipif("platform.system() == 'Windows'")
101
+ @pytest.mark.skipif("platform.system() == 'Darwin'")
102
+ @pytest.mark.slow_integration_test
103
+ def test_timeout_and_threaded_timeout_enabled_kills(monkeypatch):
104
+ monkeypatch.setenv("CLOUD_RUN_TIMEOUT_SECONDS", "1")
105
+ monkeypatch.setenv("THREADED_TIMEOUT_ENABLED", "true")
106
+ source = TEST_FUNCTIONS_DIR / "timeout" / "main.py"
107
+ target = "function"
108
+
109
+ app = create_app(target, source)
110
+
111
+ options = {}
112
+
113
+ gunicorn_app = ff_gunicorn.GunicornApplication(
114
+ app, TEST_HOST, TEST_PORT, False, **options
115
+ )
116
+
117
+ gunicorn_p = Process(target=gunicorn_app.run)
118
+ gunicorn_p.start()
119
+
120
+ _wait_for_listen(TEST_HOST, TEST_PORT)
121
+
122
+ result = requests.get("http://{}:{}/".format(TEST_HOST, TEST_PORT))
123
+
124
+ gunicorn_p.terminate()
125
+ gunicorn_p.join()
126
+
127
+ # Any exception raised in execution is a 500 error. Cloud Functions 1st gen and
128
+ # 2nd gen/Cloud Run infrastructure doing the timeout will return a 408 (gen 1)
129
+ # or 504 (gen 2/CR) at the infrastructure layer when request timeouts happen,
130
+ # and this code will only be available to the user in logs.
131
+ assert result.status_code == 500
132
+
133
+
134
+ @pytest.mark.skipif("platform.system() == 'Windows'")
135
+ @pytest.mark.skipif("platform.system() == 'Darwin'")
136
+ @pytest.mark.slow_integration_test
137
+ def test_timeout_and_threaded_timeout_enabled_but_timeout_not_exceeded_doesnt_kill(
138
+ monkeypatch,
139
+ ):
140
+ monkeypatch.setenv("CLOUD_RUN_TIMEOUT_SECONDS", "2")
141
+ monkeypatch.setenv("THREADED_TIMEOUT_ENABLED", "true")
142
+ source = TEST_FUNCTIONS_DIR / "timeout" / "main.py"
143
+ target = "function"
144
+
145
+ app = create_app(target, source)
146
+
147
+ options = {}
148
+
149
+ gunicorn_app = ff_gunicorn.GunicornApplication(
150
+ app, TEST_HOST, TEST_PORT, False, **options
151
+ )
152
+
153
+ gunicorn_p = Process(target=gunicorn_app.run)
154
+ gunicorn_p.start()
155
+
156
+ _wait_for_listen(TEST_HOST, TEST_PORT)
157
+
158
+ result = requests.get("http://{}:{}/".format(TEST_HOST, TEST_PORT))
159
+
160
+ gunicorn_p.terminate()
161
+ gunicorn_p.join()
162
+
163
+ assert result.status_code == 200
164
+
165
+
166
+ @pytest.mark.skipif("platform.system() == 'Windows'")
167
+ @pytest.mark.skipif("platform.system() == 'Darwin'")
168
+ @pytest.mark.slow_integration_test
169
+ def test_timeout_sync_worker_kills_on_timeout(
170
+ monkeypatch,
171
+ ):
172
+ monkeypatch.setenv("CLOUD_RUN_TIMEOUT_SECONDS", "1")
173
+ monkeypatch.setenv("WORKERS", 2)
174
+ monkeypatch.setenv("THREADS", 1)
175
+ source = TEST_FUNCTIONS_DIR / "timeout" / "main.py"
176
+ target = "function"
177
+
178
+ app = create_app(target, source)
179
+
180
+ options = {}
181
+
182
+ gunicorn_app = ff_gunicorn.GunicornApplication(
183
+ app, TEST_HOST, TEST_PORT, False, **options
184
+ )
185
+
186
+ gunicorn_p = Process(target=gunicorn_app.run)
187
+ gunicorn_p.start()
188
+
189
+ _wait_for_listen(TEST_HOST, TEST_PORT)
190
+
191
+ result = requests.get("http://{}:{}/".format(TEST_HOST, TEST_PORT))
192
+
193
+ gunicorn_p.terminate()
194
+ gunicorn_p.join()
195
+
196
+ assert result.status_code == 500
197
+
198
+
199
+ @pytest.mark.skipif("platform.system() == 'Windows'")
200
+ @pytest.mark.skipif("platform.system() == 'Darwin'")
201
+ @pytest.mark.slow_integration_test
202
+ def test_timeout_sync_worker_does_not_kill_if_less_than_timeout(
203
+ monkeypatch,
204
+ ):
205
+ monkeypatch.setenv("CLOUD_RUN_TIMEOUT_SECONDS", "2")
206
+ monkeypatch.setenv("WORKERS", 2)
207
+ monkeypatch.setenv("THREADS", 1)
208
+ source = TEST_FUNCTIONS_DIR / "timeout" / "main.py"
209
+ target = "function"
210
+
211
+ app = create_app(target, source)
212
+
213
+ options = {}
214
+
215
+ gunicorn_app = ff_gunicorn.GunicornApplication(
216
+ app, TEST_HOST, TEST_PORT, False, **options
217
+ )
218
+
219
+ gunicorn_p = Process(target=gunicorn_app.run)
220
+ gunicorn_p.start()
221
+
222
+ _wait_for_listen(TEST_HOST, TEST_PORT)
223
+
224
+ result = requests.get("http://{}:{}/".format(TEST_HOST, TEST_PORT))
225
+
226
+ gunicorn_p.terminate()
227
+ gunicorn_p.join()
228
+
229
+ assert result.status_code == 200
230
+
231
+
232
+ @pytest.mark.skip
233
+ def _wait_for_listen(host, port, timeout=10):
234
+ # Used in tests to make sure that the gunicorn app has booted and is
235
+ # listening before sending a test request
236
+ start_time = time.perf_counter()
237
+ while True:
238
+ try:
239
+ with socket.create_connection((host, port), timeout=timeout):
240
+ break
241
+ except OSError as ex:
242
+ time.sleep(0.01)
243
+ if time.perf_counter() - start_time >= timeout:
244
+ raise TimeoutError(
245
+ "Waited too long for port {} on host {} to start accepting "
246
+ "connections.".format(port, host)
247
+ ) from ex
248
+
249
+
250
+ @pytest.mark.skip
251
+ def _wait_for_no_listen(host, port, timeout=10):
252
+ # Used in tests to make sure that the port is actually free after
253
+ # the process binding to it should have been killed
254
+ start_time = time.perf_counter()
255
+ while True:
256
+ try:
257
+ with socket.create_connection((host, port), timeout=timeout):
258
+ time.sleep(0.01)
259
+ if time.perf_counter() - start_time >= timeout:
260
+ raise TimeoutError(
261
+ "Waited too long for port {} on host {} to stop accepting "
262
+ "connections.".format(port, host)
263
+ )
264
+ except OSError as ex:
265
+ break
@@ -25,7 +25,7 @@ import functions_framework
25
25
  def test_http_view_func_wrapper():
26
26
  function = pretend.call_recorder(lambda request: "Hello")
27
27
  request_object = pretend.stub()
28
- local_proxy = pretend.stub(_get_current_object=lambda: request_object)
28
+ local_proxy = pretend.stub(_get_current_object=lambda: request_object, headers={})
29
29
 
30
30
  view_func = functions_framework._http_view_func_wrapper(function, local_proxy)
31
31
  view_func("/some/path")
@@ -1,40 +0,0 @@
1
- # Copyright 2020 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 os
16
-
17
- import gunicorn.app.base
18
-
19
-
20
- class GunicornApplication(gunicorn.app.base.BaseApplication):
21
- def __init__(self, app, host, port, debug, **options):
22
- self.options = {
23
- "bind": "%s:%s" % (host, port),
24
- "workers": os.environ.get("WORKERS", (os.cpu_count() or 1) * 4),
25
- "threads": os.environ.get("THREADS", 1),
26
- "timeout": os.environ.get("CLOUD_RUN_TIMEOUT_SECONDS", 300),
27
- "loglevel": "error",
28
- "limit_request_line": 0,
29
- }
30
- self.options.update(options)
31
- self.app = app
32
-
33
- super().__init__()
34
-
35
- def load_config(self):
36
- for key, value in self.options.items():
37
- self.cfg.set(key, value)
38
-
39
- def load(self):
40
- return self.app