77from typing import Any , Callable , Dict , Iterable , Optional
88
99from flask import copy_current_request_context , has_request_context , request
10- from werkzeug .exceptions import BadRequest
10+ from werkzeug .exceptions import BadRequest , HTTPException
1111
1212from ..deque import LockableDeque
1313from ..utilities import TimeoutTracker
@@ -22,6 +22,42 @@ class ActionKilledException(SystemExit):
2222class ActionThread (threading .Thread ):
2323 """
2424 A native thread with extra functionality for tracking progress and thread termination.
25+
26+ Arguments:
27+ * `action` is the name of the action that's running
28+ * `target`, `name`, `args`, `kwargs` and `daemon` are passed to `threading.Thread`
29+ (though the defualt for `daemon` is changed to `True`)
30+ * `default_stop_timeout` specifies how long we wait for the `target` function to
31+ stop nicely (e.g. by checking the `stopping` Event )
32+ * `log_len` gives the number of log entries before we start dumping them
33+ * `http_error_lock` allows the calling thread to handle some
34+ errors initially. See below.
35+
36+ ## Error propagation
37+ If the `target` function throws an Exception, by default this will result in:
38+ * The thread terminating
39+ * The Action's status being set to `error`
40+ * The exception appearing in the logs with a traceback
41+ * The exception being raised in the background thread.
42+ However, `HTTPException` subclasses are used in Flask/Werkzeug web apps to
43+ return HTTP status codes indicating specific errors, and so merit being
44+ handled differently.
45+
46+ Normally, when an Action is initiated, the thread handling the HTTP request
47+ does not return immediately - it waits for a short period to check whether
48+ the Action has completed or returned an error. If an HTTPError is raised
49+ in the Action thread before the initiating thread has sent an HTTP response,
50+ we **don't** want to propagate the error here, but instead want to re-raise
51+ it in the calling thread. This will then mean that the HTTP request is
52+ answered with the appropriate error code, rather than returning a `201`
53+ code, along with a description of the task (showing that it was successfully
54+ started, but also showing that it subsequently failed with an error).
55+
56+ In order to activate this behaviour, we must pass in a `threading.Lock`
57+ object. This lock should already be acquired by the request-handling
58+ thread. If an error occurs, and this lock is acquired, the exception
59+ should not be re-raised until the calling thread has had the chance to deal
60+ with it.
2561 """
2662
2763 def __init__ (
@@ -34,6 +70,7 @@ def __init__(
3470 daemon : bool = True ,
3571 default_stop_timeout : int = 5 ,
3672 log_len : int = 100 ,
73+ http_error_lock : Optional [threading .Lock ] = None ,
3774 ):
3875 threading .Thread .__init__ (
3976 self ,
@@ -56,6 +93,8 @@ def __init__(
5693 # Event to track if the user has requested stop
5794 self .stopping : threading .Event = threading .Event ()
5895 self .default_stop_timeout : int = default_stop_timeout
96+ # Allow the calling thread to handle HTTP errors for a short time at the start
97+ self .http_error_lock = http_error_lock or threading .Lock ()
5998
6099 # Make _target, _args, and _kwargs available to the subclass
61100 self ._target : Optional [Callable ] = target
@@ -85,6 +124,7 @@ def __init__(
85124 self ._request_time : datetime .datetime = datetime .datetime .now ()
86125 self ._start_time : Optional [datetime .datetime ] = None # Task start time
87126 self ._end_time : Optional [datetime .datetime ] = None # Task end time
127+ self ._exception : Optional [Exception ] = None # Propagate exceptions helpfully
88128
89129 # Public state properties
90130 self .progress : Optional [int ] = None # Percent progress of the task
@@ -151,6 +191,11 @@ def cancelled(self) -> bool:
151191 """Alias of `stopped`"""
152192 return self .stopped
153193
194+ @property
195+ def exception (self ) -> Optional [Exception ]:
196+ """The Exception that caused the action to fail."""
197+ return self ._exception
198+
154199 def update_progress (self , progress : int ):
155200 """
156201 Update the progress of the ActionThread.
@@ -214,15 +259,29 @@ def wrapped(*args, **kwargs):
214259 # Set state to stopped
215260 self ._status = "cancelled"
216261 self .progress = None
262+ except HTTPException as e :
263+ self ._exception = e
264+ # If the lock is acquired elsewhere, assume the error
265+ # will be handled there.
266+ if self .http_error_lock .acquire (blocking = False ):
267+ self .http_error_lock .release ()
268+ logging .error (
269+ "An HTTPException occurred in an action thread, but "
270+ "the parent request was no longer waiting for it."
271+ )
272+ logging .error (traceback .format_exc ())
273+ raise e
217274 except Exception as e : # skipcq: PYL-W0703
275+ self ._exception = e
218276 logging .error (traceback .format_exc ())
219- self ._return_value = str (e )
220- self ._status = "error"
221277 raise e
222278 finally :
223279 self ._end_time = datetime .datetime .now ()
224280 logging .getLogger ().removeHandler (handler ) # Stop logging this thread
225281 # If we don't remove the handler, it's a memory leak.
282+ if self ._exception :
283+ self ._return_value = str (self ._exception )
284+ self ._status = "error"
226285
227286 return wrapped
228287
0 commit comments