Skip to content

Commit a6bf456

Browse files
authored
Updater improvements (python-telegram-bot#1018)
- Refactor bootstrap phase to be resilient for network errors - Retry bootstrap phase indefinitely (by default) on network errors - Improved logs - Improved unitests for polling updater fixes python-telegram-bot#605
1 parent 811369d commit a6bf456

File tree

2 files changed

+187
-77
lines changed

2 files changed

+187
-77
lines changed

telegram/ext/updater.py

Lines changed: 108 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -149,15 +149,15 @@ def _thread_wrapper(self, target, *args, **kwargs):
149149
target(*args, **kwargs)
150150
except Exception:
151151
self.__exception_event.set()
152-
self.logger.exception('unhandled exception')
152+
self.logger.exception('unhandled exception in %s', thr_name)
153153
raise
154154
self.logger.debug('{0} - ended'.format(thr_name))
155155

156156
def start_polling(self,
157157
poll_interval=0.0,
158158
timeout=10,
159159
clean=False,
160-
bootstrap_retries=0,
160+
bootstrap_retries=-1,
161161
read_latency=2.,
162162
allowed_updates=None):
163163
"""Starts polling updates from Telegram.
@@ -171,8 +171,8 @@ def start_polling(self,
171171
bootstrap_retries (:obj:`int`, optional): Whether the bootstrapping phase of the
172172
`Updater` will retry on failures on the Telegram server.
173173
174-
* < 0 - retry indefinitely
175-
* 0 - no retries (default)
174+
* < 0 - retry indefinitely (default)
175+
* 0 - no retries
176176
* > 0 - retry up to X times
177177
178178
allowed_updates (List[:obj:`str`], optional): Passed to
@@ -229,8 +229,8 @@ def start_webhook(self,
229229
bootstrap_retries (:obj:`int`, optional): Whether the bootstrapping phase of the
230230
`Updater` will retry on failures on the Telegram server.
231231
232-
* < 0 - retry indefinitely
233-
* 0 - no retries (default)
232+
* < 0 - retry indefinitely (default)
233+
* 0 - no retries
234234
* > 0 - retry up to X times
235235
236236
webhook_url (:obj:`str`, optional): Explicitly specify the webhook url. Useful behind
@@ -242,7 +242,6 @@ def start_webhook(self,
242242
:obj:`Queue`: The update queue that can be filled from the main thread.
243243
244244
"""
245-
246245
with self.__lock:
247246
if not self.running:
248247
self.running = True
@@ -262,46 +261,72 @@ def _start_polling(self, poll_interval, timeout, read_latency, bootstrap_retries
262261
# updates from Telegram and inserts them in the update queue of the
263262
# Dispatcher.
264263

265-
cur_interval = poll_interval
266-
self.logger.debug('Updater thread started')
264+
self.logger.debug('Updater thread started (polling)')
267265

268266
self._bootstrap(bootstrap_retries, clean=clean, webhook_url='', allowed_updates=None)
269267

268+
self.logger.debug('Bootstrap done')
269+
270+
def polling_action_cb():
271+
updates = self.bot.get_updates(
272+
self.last_update_id, timeout=timeout, read_latency=read_latency,
273+
allowed_updates=allowed_updates)
274+
275+
if updates:
276+
if not self.running:
277+
self.logger.debug('Updates ignored and will be pulled again on restart')
278+
else:
279+
for update in updates:
280+
self.update_queue.put(update)
281+
self.last_update_id = updates[-1].update_id + 1
282+
283+
return True
284+
285+
def polling_onerr_cb(exc):
286+
# Put the error into the update queue and let the Dispatcher
287+
# broadcast it
288+
self.update_queue.put(exc)
289+
290+
self._network_loop_retry(polling_action_cb, polling_onerr_cb, 'getting Updates',
291+
poll_interval)
292+
293+
def _network_loop_retry(self, action_cb, onerr_cb, description, interval):
294+
"""Perform a loop calling `action_cb`, retrying after network errors.
295+
296+
Stop condition for loop: `self.running` evaluates False or return value of `action_cb`
297+
evaluates False.
298+
299+
Args:
300+
action_cb (:obj:`callable`): Network oriented callback function to call.
301+
onerr_cb (:obj:`callable`): Callback to call when TelegramError is caught. Receives the
302+
exception object as a parameter.
303+
description (:obj:`str`): Description text to use for logs and exception raised.
304+
interval (:obj:`float` | :obj:`int`): Interval to sleep between each call to
305+
`action_cb`.
306+
307+
"""
308+
self.logger.debug('Start network loop retry %s', description)
309+
cur_interval = interval
270310
while self.running:
271311
try:
272-
updates = self.bot.get_updates(
273-
self.last_update_id,
274-
timeout=timeout,
275-
read_latency=read_latency,
276-
allowed_updates=allowed_updates)
312+
if not action_cb():
313+
break
277314
except RetryAfter as e:
278-
self.logger.info(str(e))
315+
self.logger.info('%s', e)
279316
cur_interval = 0.5 + e.retry_after
280317
except TimedOut as toe:
281-
self.logger.debug('Timed out getting Updates: %s', toe)
282-
# If get_updates() failed due to timeout, we should retry asap.
318+
self.logger.debug('Timed out %s: %s', description, toe)
319+
# If failure is due to timeout, we should retry asap.
283320
cur_interval = 0
321+
except InvalidToken as pex:
322+
self.logger.error('Invalid token; aborting')
323+
raise pex
284324
except TelegramError as te:
285-
self.logger.error('Error while getting Updates: %s', te)
286-
287-
# Put the error into the update queue and let the Dispatcher
288-
# broadcast it
289-
self.update_queue.put(te)
290-
325+
self.logger.error('Error while %s: %s', description, te)
326+
onerr_cb(te)
291327
cur_interval = self._increase_poll_interval(cur_interval)
292328
else:
293-
if not self.running:
294-
if len(updates) > 0:
295-
self.logger.debug('Updates ignored and will be pulled '
296-
'again on restart.')
297-
break
298-
299-
if updates:
300-
for update in updates:
301-
self.update_queue.put(update)
302-
self.last_update_id = updates[-1].update_id + 1
303-
304-
cur_interval = poll_interval
329+
cur_interval = interval
305330

306331
if cur_interval:
307332
sleep(cur_interval)
@@ -319,7 +344,7 @@ def _increase_poll_interval(current_interval):
319344

320345
def _start_webhook(self, listen, port, url_path, cert, key, bootstrap_retries, clean,
321346
webhook_url, allowed_updates):
322-
self.logger.debug('Updater thread started')
347+
self.logger.debug('Updater thread started (webhook)')
323348
use_ssl = cert is not None and key is not None
324349
if not url_path.startswith('/'):
325350
url_path = '/{0}'.format(url_path)
@@ -370,39 +395,56 @@ def _check_ssl_cert(self, cert, key):
370395
def _gen_webhook_url(listen, port, url_path):
371396
return 'https://{listen}:{port}{path}'.format(listen=listen, port=port, path=url_path)
372397

373-
def _bootstrap(self, max_retries, clean, webhook_url, allowed_updates, cert=None):
374-
retries = 0
375-
while 1:
376-
377-
try:
378-
if clean:
379-
# Disable webhook for cleaning
380-
self.bot.delete_webhook()
381-
self._clean_updates()
382-
sleep(1)
383-
384-
self.bot.set_webhook(
385-
url=webhook_url, certificate=cert, allowed_updates=allowed_updates)
386-
except (Unauthorized, InvalidToken):
387-
raise
388-
except TelegramError:
389-
msg = 'error in bootstrap phase; try={0} max_retries={1}'.format(retries,
390-
max_retries)
391-
if max_retries < 0 or retries < max_retries:
392-
self.logger.warning(msg)
393-
retries += 1
394-
else:
395-
self.logger.exception(msg)
396-
raise
398+
def _bootstrap(self, max_retries, clean, webhook_url, allowed_updates, cert=None,
399+
bootstrap_interval=5):
400+
retries = [0]
401+
402+
def bootstrap_del_webhook():
403+
self.bot.delete_webhook()
404+
return False
405+
406+
def bootstrap_clean_updates():
407+
self.logger.debug('Cleaning updates from Telegram server')
408+
updates = self.bot.get_updates()
409+
while updates:
410+
updates = self.bot.get_updates(updates[-1].update_id + 1)
411+
return False
412+
413+
def bootstrap_set_webhook():
414+
self.bot.set_webhook(
415+
url=webhook_url, certificate=cert, allowed_updates=allowed_updates)
416+
return False
417+
418+
def bootstrap_onerr_cb(exc):
419+
if not isinstance(exc, Unauthorized) and (max_retries < 0 or retries[0] < max_retries):
420+
retries[0] += 1
421+
self.logger.warning('Failed bootstrap phase; try=%s max_retries=%s',
422+
retries[0], max_retries)
397423
else:
398-
break
424+
self.logger.error('Failed bootstrap phase after %s retries (%s)', retries[0], exc)
425+
raise exc
426+
427+
# Cleaning pending messages is done by polling for them - so we need to delete webhook if
428+
# one is configured.
429+
# We also take this chance to delete pre-configured webhook if this is a polling Updater.
430+
# NOTE: We don't know ahead if a webhook is configured, so we just delete.
431+
if clean or not webhook_url:
432+
self._network_loop_retry(bootstrap_del_webhook, bootstrap_onerr_cb,
433+
'bootstrap del webhook', bootstrap_interval)
434+
retries[0] = 0
435+
436+
# Clean pending messages, if requested.
437+
if clean:
438+
self._network_loop_retry(bootstrap_clean_updates, bootstrap_onerr_cb,
439+
'bootstrap clean updates', bootstrap_interval)
440+
retries[0] = 0
399441
sleep(1)
400442

401-
def _clean_updates(self):
402-
self.logger.debug('Cleaning updates from Telegram server')
403-
updates = self.bot.get_updates()
404-
while updates:
405-
updates = self.bot.get_updates(updates[-1].update_id + 1)
443+
# Restore/set webhook settings, if needed. Again, we don't know ahead if a webhook is set,
444+
# so we set it anyhow.
445+
if webhook_url:
446+
self._network_loop_retry(bootstrap_set_webhook, bootstrap_onerr_cb,
447+
'bootstrap set webhook', bootstrap_interval)
406448

407449
def stop(self):
408450
"""Stops the polling/webhook thread, the dispatcher and the job queue."""

tests/test_updater.py

Lines changed: 79 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
from functools import partial
2424
from queue import Queue
2525
from random import randrange
26-
from threading import Thread
26+
from threading import Thread, Event
2727
from time import sleep
2828

2929
try:
@@ -38,7 +38,7 @@
3838
from future.builtins import bytes
3939

4040
from telegram import TelegramError, Message, User, Chat, Update, Bot
41-
from telegram.error import Unauthorized, InvalidToken
41+
from telegram.error import Unauthorized, InvalidToken, TimedOut, RetryAfter
4242
from telegram.ext import Updater
4343

4444
signalskip = pytest.mark.skipif(sys.platform == 'win32',
@@ -58,31 +58,94 @@ class TestUpdater(object):
5858
message_count = 0
5959
received = None
6060
attempts = 0
61+
err_handler_called = Event()
62+
cb_handler_called = Event()
6163

6264
@pytest.fixture(autouse=True)
6365
def reset(self):
6466
self.message_count = 0
6567
self.received = None
6668
self.attempts = 0
69+
self.err_handler_called.clear()
70+
self.cb_handler_called.clear()
6771

6872
def error_handler(self, bot, update, error):
6973
self.received = error.message
74+
self.err_handler_called.set()
7075

7176
def callback(self, bot, update):
7277
self.received = update.message.text
78+
self.cb_handler_called.set()
7379

74-
# TODO: test clean= argument
80+
# TODO: test clean= argument of Updater._bootstrap
7581

76-
def test_error_on_get_updates(self, monkeypatch, updater):
82+
@pytest.mark.parametrize(('error',),
83+
argvalues=[(TelegramError('Test Error 2'),),
84+
(Unauthorized('Test Unauthorized'),)],
85+
ids=('TelegramError', 'Unauthorized'))
86+
def test_get_updates_normal_err(self, monkeypatch, updater, error):
7787
def test(*args, **kwargs):
78-
raise TelegramError('Test Error 2')
88+
raise error
89+
90+
monkeypatch.setattr('telegram.Bot.get_updates', test)
91+
monkeypatch.setattr('telegram.Bot.set_webhook', lambda *args, **kwargs: True)
92+
updater.dispatcher.add_error_handler(self.error_handler)
93+
updater.start_polling(0.01)
94+
95+
# Make sure that the error handler was called
96+
self.err_handler_called.wait()
97+
assert self.received == error.message
98+
99+
# Make sure that Updater polling thread keeps running
100+
self.err_handler_called.clear()
101+
self.err_handler_called.wait()
102+
103+
def test_get_updates_bailout_err(self, monkeypatch, updater, caplog):
104+
error = InvalidToken()
105+
106+
def test(*args, **kwargs):
107+
raise error
108+
109+
with caplog.at_level(logging.DEBUG):
110+
monkeypatch.setattr('telegram.Bot.get_updates', test)
111+
monkeypatch.setattr('telegram.Bot.set_webhook', lambda *args, **kwargs: True)
112+
updater.dispatcher.add_error_handler(self.error_handler)
113+
updater.start_polling(0.01)
114+
assert self.err_handler_called.wait(0.5) is not True
115+
116+
# NOTE: This test might hit a race condition and fail (though the 0.5 seconds delay above
117+
# should work around it).
118+
# NOTE: Checking Updater.running is problematic because it is not set to False when there's
119+
# an unhandled exception.
120+
# TODO: We should have a way to poll Updater status and decide if it's running or not.
121+
assert any('unhandled exception in updater' in rec.getMessage() for rec in
122+
caplog.get_records('call'))
123+
124+
@pytest.mark.parametrize(('error',),
125+
argvalues=[(RetryAfter(0.01),),
126+
(TimedOut(),)],
127+
ids=('RetryAfter', 'TimedOut'))
128+
def test_get_updates_retries(self, monkeypatch, updater, error):
129+
event = Event()
130+
131+
def test(*args, **kwargs):
132+
event.set()
133+
raise error
79134

80135
monkeypatch.setattr('telegram.Bot.get_updates', test)
81136
monkeypatch.setattr('telegram.Bot.set_webhook', lambda *args, **kwargs: True)
82137
updater.dispatcher.add_error_handler(self.error_handler)
83138
updater.start_polling(0.01)
84-
sleep(.1)
85-
assert self.received == 'Test Error 2'
139+
140+
# Make sure that get_updates was called, but not the error handler
141+
event.wait()
142+
assert self.err_handler_called.wait(0.5) is not True
143+
assert self.received != error.message
144+
145+
# Make sure that Updater polling thread keeps running
146+
event.clear()
147+
event.wait()
148+
assert self.err_handler_called.wait(0.5) is not True
86149

87150
def test_webhook(self, monkeypatch, updater):
88151
q = Queue()
@@ -145,17 +208,21 @@ def test_webhook_no_ssl(self, monkeypatch, updater):
145208
sleep(.2)
146209
assert q.get(False) == update
147210

148-
def test_bootstrap_retries_success(self, monkeypatch, updater):
211+
@pytest.mark.parametrize(('error',),
212+
argvalues=[(TelegramError(''),)],
213+
ids=('TelegramError',))
214+
def test_bootstrap_retries_success(self, monkeypatch, updater, error):
149215
retries = 2
150216

151217
def attempt(_, *args, **kwargs):
152218
if self.attempts < retries:
153219
self.attempts += 1
154-
raise TelegramError('')
220+
raise error
155221

156222
monkeypatch.setattr('telegram.Bot.set_webhook', attempt)
157223

158-
updater._bootstrap(retries, False, 'path', None)
224+
updater.running = True
225+
updater._bootstrap(retries, False, 'path', None, bootstrap_interval=0)
159226
assert self.attempts == retries
160227

161228
@pytest.mark.parametrize(('error', 'attempts'),
@@ -172,8 +239,9 @@ def attempt(_, *args, **kwargs):
172239

173240
monkeypatch.setattr('telegram.Bot.set_webhook', attempt)
174241

242+
updater.running = True
175243
with pytest.raises(type(error)):
176-
updater._bootstrap(retries, False, 'path', None)
244+
updater._bootstrap(retries, False, 'path', None, bootstrap_interval=0)
177245
assert self.attempts == attempts
178246

179247
def test_webhook_invalid_posts(self, updater):

0 commit comments

Comments
 (0)