8000 Ensure Safe Handler Looping in `Application.process_update/error` by Bibo-Joshi · Pull Request #4802 · python-telegram-bot/python-telegram-bot · GitHub
[go: up one dir, main page]

Skip to content

Ensure Safe Handler Looping in Application.process_update/error #4802

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Jun 4, 2025
14 changes: 14 additions & 0 deletions changes/unreleased/4802.RMLufX4UazobYg5aZojyoD.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
bugfixes = """
Fixed a bug where calling ``Application.remove/add_handler`` during update handling can cause a ``RuntimeError`` in ``Application.process_update``.

.. hint::
Calling ``Application.add/remove_handler`` now has no influence on calls to :meth:`process_update` that are
already in progress. The same holds for ``Application.add/remove_error_handler`` and ``Application.process_error``, respectively.

.. warning::
This behavior should currently be considered an implementation detail and not as guaranteed behavior.
"""
[[pull_requests]]
uid = "4802"
author_uid = "Bibo-Joshi"
closes_threads = ["4803"]
50 changes: 45 additions & 5 deletions src/telegram/ext/_application.py
Original file line number Diff line number Diff line change
Expand Up @@ -1256,8 +1256,14 @@ async def process_update(self, update: object) -> None:
context = None
any_blocking = False # Flag which is set to True if any handler specifies block=True

for handlers in self.handlers.values():
# We copy the lists to avoid issues with concurrent modification of the
# handlers (groups or handlers in groups) while iterating over it via add/remove_handler.
# Currently considered implementation detail as described in docstrings of
# add/remove_handler
# do *not* use `copy.deepcopy` here, as we don't want to deepcopy the handlers themselves
for handlers in [v.copy() for v in self.handlers.values()]:
try:
# no copy needed b/c we copy above
for handler in handlers:
check = handler.check_update(update) # Should the handler handle this update?
if check is None or check is False:
Expand Down Expand Up @@ -1343,6 +1349,14 @@ def add_handler(self, handler: BaseHandler[Any, CCT, Any], group: int = DEFAULT_
might lead to race conditions and undesired behavior. In particular, current
conversation states may be overridden by the loaded data.

Hint:
This method currently has no influence on calls to :meth:`process_update` that are
already in progress.

.. warning::
This behavior should currently be considered an implementation detail and not as
guaranteed behavior.

Args:
handler (:class:`telegram.ext.BaseHandler`): A BaseHandler instance.
group (:obj:`int`, optional): The group identifier. Default is ``0``.
Expand Down Expand Up @@ -1444,6 +1458,14 @@ def remove_handler(
) -> None:
"""Remove a handler from the specified group.

Hint:
This method currently has no influence on calls to :meth:`process_update` that are
already in progress.

.. warning::
This behavior should currently be considered an implementation detail and not as
guaranteed behavior.

Args:
handler (:class:`telegram.ext.BaseHandler`): A :class:`telegram.ext.BaseHandler`
instance.
Expand Down Expand Up @@ -1774,6 +1796,14 @@ def add_error_handler(
Examples:
:any:`Errorhandler Bot <examples.errorhandlerbot>`

Hint:
This method currently has no influence on calls to :meth:`process_error` that are
already in progress.

.. warning::
This behavior should currently be considered an implementation detail and not as
guaranteed behavior.

.. seealso:: :wiki:`Exceptions, Warnings and Logging <Exceptions%2C-Warnings-and-Logging>`

Args:
Expand All @@ -1797,6 +1827,14 @@ async def callback(update: Optional[object], context: CallbackContext)
def remove_error_handler(self, callback: HandlerCallback[object, CCT, None]) -> None:
"""Removes an error handler.

Hint:
This method currently has no influence on calls to :meth:`process_error` that are
already in progress.

.. warning::
This behavior should currently be considered an implementation detail and not as
guaranteed behavior.

Args:
callback (:term:`coroutine function`): The error handler to remove.

Expand Down Expand Up @@ -1838,10 +1876,12 @@ async def process_error(
:class:`telegram.ext.ApplicationHandlerStop`. :obj:`False`, otherwise.
"""
if self.error_handlers:
for (
callback,
block,
) in self.error_handlers.items():
# We copy the list to avoid issues with concurrent modification of the
# error handlers while iterating over it via add/remove_error_handler.
# Currently considered implementation detail as described in docstrings of
# add/remove_error_handler
error_handler_items = list(self.error_handlers.items())
for callback, block in error_handler_items:
try:
context = self.context_types.context.from_error(
update=update,
Expand Down
89 changes: 89 additions & 0 deletions tests/ext/test_application.py
60DB
Original file line number Diff line number Diff line change
Expand Up @@ -2583,6 +2583,70 @@ async def callback(update, context):
assert received_updates == {2}
assert len(caplog.records) == 0

@pytest.mark.parametrize("change_type", ["remove", "add"])
async def test_process_update_handler_change_groups_during_iteration(self, app, change_type):
run_groups = set()

async def dummy_callback(_, __, g: int):
run_groups.add(g)

for group in range(10, 20):
handler = TypeHandler(int, functools.partial(dummy_callback, g=group))
app.add_handler(handler, group=group)

async def wait_callback(_, context):
# Trigger a change of the app.handlers dict during the iteration
if change_type == "remove":
context.application.remove_handler(handler, group)
else:
context.application.add_handler(
TypeHandler(int, functools.partial(dummy_callback, g=42)), group=42
)

app.add_handler(TypeHandler(int, wait_callback))

async with app:
await app.process_update(1)

# check that exactly those handlers were called that were configured when
# process_update was called
assert run_groups == set(range(10, 20))

async def test_process_update_handler_change_group_during_iteration(self, app):
async def dummy_callback(_, __):
pass

checked_handlers = set()

class TrackHandler(TypeHandler):
def __init__(self, name: str, *args, **kwargs):
self.name = name
super().__init__(*args, **kwargs)

def check_update(self, update: object) -> bool:
checked_handlers.add(self.name)
return super().check_update(update)

remove_handler = TrackHandler("remove", int, dummy_callback)
add_handler = TrackHandler("add", int, dummy_callback)

class TriggerHandler(TypeHandler):
def check_update(self, update: object) -> bool:
# Trigger a change of the app.handlers *in the same group* during the iteration
app.remove_handler(remove_handler)
app.add_handler(add_handler)
# return False to ensure that additional handlers in the same group are checked
return False

app.add_handler(TriggerHandler(str, dummy_callback))
app.add_handler(remove_handler)
async with app:
await app.process_update("string update")

# check that exactly those handlers were checked that were configured when
# process_update was called
assert checked_handlers == {"remove"}

async def test_process_error_exception_in_building_context(self, monkeypatch, caplog, app):
# Makes sure that exceptions in building the context don't stop the application
exception = ValueError("TestException")
Expand Down Expand Up @@ -2622,3 +2686,28 @@ async def callback(update, context):

assert received_errors == {2}
assert len(caplog.records) == 0

@pytest.mark.parametrize("change_type", ["remove", "add"])
async def test_process_error_change_during_iteration(self, app, change_type):
called_handlers = set()

async def dummy_process_error(name: str, *_, **__):
called_handlers.add(name)

add_error_handler = functools.partial(dummy_process_error, "add_handler")
remove_error_handler = functools.partial(dummy_process_error, "remove_handler")

async def trigger_change(*_, **__):
if change_type == "remove":
app.remove_error_handler(remove_error_handler)
else:
app.add_error_handler(add_error_handler)

app.add_error_handler(trigger_change)
app.add_error_handler(remove_error_handler)
async with app:
await app.process_error(update=None, error=None)

# check that exactly those handlers were checked that were configured when
# add_error_handler was called
assert called_handlers == {"remove_handler"}
Loading
0