Skip to content

Commit c878078

Browse files
committed
fix #482: callback memory repo
1 parent 09a8cec commit c878078

File tree

6 files changed

+151
-49
lines changed

6 files changed

+151
-49
lines changed

pybotx/bot/bot.py

+7-2
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,11 @@
212212
BotXAPIUsersAsCSVRequestPayload,
213213
UsersAsCSVMethod,
214214
)
215-
from pybotx.constants import BOTX_DEFAULT_TIMEOUT, STICKER_PACKS_PER_PAGE
215+
from pybotx.constants import (
216+
AUTODELETE_CALLBACK_DEFAULT_TIMEOUT,
217+
BOTX_DEFAULT_TIMEOUT,
218+
STICKER_PACKS_PER_PAGE,
219+
)
216220
from pybotx.converters import optional_sequence_to_list
217221
from pybotx.image_validators import (
218222
ensure_file_content_is_png,
@@ -264,6 +268,7 @@ def __init__(
264268
httpx_client: Optional[httpx.AsyncClient] = None,
265269
exception_handlers: Optional[ExceptionHandlersDict] = None,
266270
default_callback_timeout: float = BOTX_DEFAULT_TIMEOUT,
271+
autodete_callbacks_timeout: float = AUTODELETE_CALLBACK_DEFAULT_TIMEOUT,
267272
callback_repo: Optional[CallbackRepoProto] = None,
268273
) -> None:
269274
if not collectors:
@@ -283,7 +288,7 @@ def __init__(
283288
self._httpx_client = httpx_client or httpx.AsyncClient()
284289

285290
if not callback_repo:
286-
callback_repo = CallbackMemoryRepo()
291+
callback_repo = CallbackMemoryRepo(timeout=autodete_callbacks_timeout)
287292

288293
self._callbacks_manager = CallbackManager(callback_repo)
289294

pybotx/bot/callbacks/callback_memory_repo.py

+27-11
Original file line numberDiff line numberDiff line change
@@ -3,43 +3,57 @@
33
from uuid import UUID
44

55
from pybotx.bot.callbacks.callback_repo_proto import CallbackRepoProto
6-
from pybotx.bot.exceptions import BotShuttingDownError, BotXMethodCallbackNotFoundError
6+
from pybotx.bot.exceptions import BotShuttingDownError
77
from pybotx.client.exceptions.callbacks import CallbackNotReceivedError
8+
from pybotx.logger import logger
89
from pybotx.models.method_callbacks import BotXMethodCallback
910

1011
if TYPE_CHECKING:
1112
from asyncio import Future # noqa: WPS458
1213

1314

1415
class CallbackMemoryRepo(CallbackRepoProto):
15-
def __init__(self) -> None:
16+
def __init__(self, timeout: float = 0) -> None:
1617
self._callback_futures: Dict[UUID, "Future[BotXMethodCallback]"] = {}
18+
self.timeout = timeout
1719

1820
async def create_botx_method_callback(self, sync_id: UUID) -> None:
19-
self._callback_futures[sync_id] = asyncio.Future()
21+
self._callback_futures.setdefault(sync_id, asyncio.Future())
2022

2123
async def set_botx_method_callback_result(
2224
self,
2325
callback: BotXMethodCallback,
2426
) -> None:
2527
sync_id = callback.sync_id
2628

27-
future = self._get_botx_method_callback(sync_id)
29+
if sync_id not in self._callback_futures:
30+
logger.warning(
31+
f"Callback `{sync_id}` doesn't exist yet or already "
32+
f"waited or timed out. Waiting for {self.timeout}s "
33+
f"for it or will be ignored.",
34+
)
35+
self._callback_futures.setdefault(sync_id, asyncio.Future())
36+
asyncio.create_task(self._wait_and_drop_orphan_callback(sync_id))
37+
38+
future = self._callback_futures[sync_id]
2839
future.set_result(callback)
2940

3041
async def wait_botx_method_callback(
3142
self,
3243
sync_id: UUID,
3344
timeout: float,
3445
) -> BotXMethodCallback:
35-
future = self._get_botx_method_callback(sync_id)
46+
future = self._callback_futures[sync_id]
3647

3748
try:
38-
return await asyncio.wait_for(future, timeout=timeout)
49+
result = await asyncio.wait_for(future, timeout=timeout)
3950
except asyncio.TimeoutError as exc:
4051
del self._callback_futures[sync_id] # noqa: WPS420
4152
raise CallbackNotReceivedError(sync_id) from exc
4253

54+
del self._callback_futures[sync_id] # noqa: WPS420
55+
return result
56+
4357
async def pop_botx_method_callback(
4458
self,
4559
sync_id: UUID,
@@ -55,8 +69,10 @@ async def stop_callbacks_waiting(self) -> None:
5569
),
5670
)
5771

58-
def _get_botx_method_callback(self, sync_id: UUID) -> "Future[BotXMethodCallback]":
59-
try:
60-
return self._callback_futures[sync_id]
61-
except KeyError:
62-
raise BotXMethodCallbackNotFoundError(sync_id) from None
72+
async def _wait_and_drop_orphan_callback(self, sync_id: UUID) -> None:
73+
await asyncio.sleep(self.timeout)
74+
if sync_id not in self._callback_futures:
75+
return
76+
77+
self._callback_futures.pop(sync_id, None)
78+
logger.debug(f"Callback `{sync_id}` was dropped")

pybotx/client/notifications_api/direct_notification.py

+1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import asyncio
12
from typing import Any, Dict, List, Literal, Optional, Union
23
from uuid import UUID
34

pybotx/constants.py

+1
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@
1111
MAX_NOTIFICATION_BODY_LENGTH: Final = 4096
1212
MAX_FILE_LEN_IN_LOGS: Final = 64
1313
BOTX_DEFAULT_TIMEOUT: Final = 60
14+
AUTODELETE_CALLBACK_DEFAULT_TIMEOUT: Final = 30

pyproject.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "pybotx"
3-
version = "0.69.1"
3+
version = "0.69.2"
44
description = "A python library for interacting with eXpress BotX API"
55
authors = [
66
"Sidnev Nikolay <[email protected]>",

tests/client/test_botx_method_callback.py

+114-35
Original file line numberDiff line numberDiff line change
@@ -108,32 +108,48 @@ async def call_foo_bar(
108108

109109
async def test__botx_method_callback__callback_not_found(
110110
bot_account: BotAccountWithSecret,
111+
loguru_caplog: pytest.LogCaptureFixture,
111112
) -> None:
112113
# - Arrange -
113-
built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
114+
memory_repo = CallbackMemoryRepo(timeout=0.5)
115+
built_bot = Bot(
116+
collectors=[HandlerCollector()],
117+
bot_accounts=[bot_account],
118+
callback_repo=memory_repo,
119+
)
114120

115121
# - Act -
116122
async with lifespan_wrapper(built_bot) as bot:
117-
with pytest.raises(BotXMethodCallbackNotFoundError) as exc:
118-
await bot.set_raw_botx_method_result(
119-
{
120-
"status": "error",
121-
"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3",
122-
"reason": "chat_not_found",
123-
"errors": [],
124-
"error_data": {
125-
"group_chat_id": "705df263-6bfd-536a-9d51-13524afaab5c",
126-
"error_description": (
127-
"Chat with id 705df263-6bfd-536a-9d51-13524afaab5c not found"
128-
),
129-
},
123+
await bot.set_raw_botx_method_result(
124+
{
125+
"status": "error",
126+
"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3",
127+
"reason": "chat_not_found",
128+
"errors": [],
129+
"error_data": {
130+
"group_chat_id": "705df263-6bfd-536a-9d51-13524afaab5c",
131+
"error_description": (
132+
"Chat with id 705df263-6bfd-536a-9d51-13524afaab5c not found"
133+
),
130134
},
131-
verify_request=False,
132-
)
135+
},
136+
verify_request=False,
137+
)
133138

134139
# - Assert -
135-
assert "Callback `21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3` doesn't exist" in str(
136-
exc.value,
140+
assert (
141+
"Callback `21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3` doesn't exist"
142+
in loguru_caplog.text
143+
)
144+
assert memory_repo._callback_futures.get(
145+
UUID("21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"),
146+
)
147+
148+
await asyncio.sleep(0.7)
149+
# Drop callback after timeout
150+
assert (
151+
memory_repo._callback_futures.get(UUID("21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"))
152+
is None
137153
)
138154

139155

@@ -303,7 +319,12 @@ async def test__botx_method_callback__callback_received_after_timeout(
303319
},
304320
),
305321
)
306-
built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account])
322+
memory_repo = CallbackMemoryRepo(timeout=0.5)
323+
built_bot = Bot(
324+
collectors=[HandlerCollector()],
325+
bot_accounts=[bot_account],
326+
callback_repo=memory_repo,
327+
)
307328

308329
built_bot.call_foo_bar = types.MethodType(call_foo_bar, built_bot)
309330

@@ -312,26 +333,28 @@ async def test__botx_method_callback__callback_received_after_timeout(
312333
with pytest.raises(CallbackNotReceivedError) as not_received_exc:
313334
await bot.call_foo_bar(bot_id, baz=1, callback_timeout=0)
314335

315-
with pytest.raises(BotXMethodCallbackNotFoundError) as not_found_exc:
316-
await bot.set_raw_botx_method_result(
317-
{
318-
"status": "error",
319-
"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3",
320-
"reason": "quux_error",
321-
"errors": [],
322-
"error_data": {
323-
"group_chat_id": "705df263-6bfd-536a-9d51-13524afaab5c",
324-
"error_description": (
325-
"Chat with id 705df263-6bfd-536a-9d51-13524afaab5c not found"
326-
),
327-
},
336+
await bot.set_raw_botx_method_result(
337+
{
338+
"status": "error",
339+
"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3",
340+
"reason": "quux_error",
341+
"errors": [],
342+
"error_data": {
343+
"group_chat_id": "705df263-6bfd-536a-9d51-13524afaab5c",
344+
"error_description": (
345+
"Chat with id 705df263-6bfd-536a-9d51-13524afaab5c not found"
346+
),
328347
},
329-
verify_request=False,
330-
)
348+
},
349+
verify_request=False,
350+
)
331351

332352
# - Assert -
333353
assert "hasn't been received" in str(not_received_exc.value)
334-
assert "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3" in str(not_found_exc.value)
354+
assert (
355+
"Callback `21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3` doesn't exist"
356+
in loguru_caplog.text
357+
)
335358
assert endpoint.called
336359

337360

@@ -611,6 +634,62 @@ async def test__botx_method_callback__bot_wait_callback_after_its_receiving(
611634
assert endpoint.called
612635

613636

637+
async def test__botx_method_callback__callback_received_before_its_expecting(
638+
respx_mock: MockRouter,
639+
httpx_client: httpx.AsyncClient,
640+
host: str,
641+
bot_id: UUID,
642+
bot_account: BotAccountWithSecret,
643+
) -> None:
644+
"""https://github.com/ExpressApp/pybotx/issues/482."""
645+
# - Arrange -
646+
endpoint = respx_mock.post(
647+
f"https://{host}/foo/bar",
648+
json={"baz": 1},
649+
headers={"Content-Type": "application/json"},
650+
).mock(
651+
return_value=httpx.Response(
652+
HTTPStatus.ACCEPTED,
653+
json={
654+
"status": "ok",
655+
"result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"},
656+
},
657+
),
658+
)
659+
built_bot = Bot(
660+
collectors=[HandlerCollector()],
661+
bot_accounts=[bot_account],
662+
httpx_client=httpx_client,
663+
callback_repo=CallbackMemoryRepo(timeout=0.5),
664+
)
665+
666+
built_bot.call_foo_bar = types.MethodType(call_foo_bar, built_bot)
667+
668+
# - Act -
669+
async with lifespan_wrapper(built_bot) as bot:
670+
await bot.set_raw_botx_method_result(
671+
{
672+
"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3",
673+
"status": "ok",
674+
"result": {},
675+
},
676+
verify_request=False,
677+
)
678+
foo_bar = await bot.call_foo_bar(bot_id, baz=1, wait_callback=False)
679+
680+
callback = await bot.wait_botx_method_callback(foo_bar)
681+
682+
await asyncio.sleep(1)
683+
684+
# - Assert -
685+
assert callback == BotAPIMethodSuccessfulCallback(
686+
sync_id=foo_bar,
687+
status="ok",
688+
result={},
689+
)
690+
assert endpoint.called
691+
692+
614693
async def test__botx_method_callback__bot_dont_wait_received_callback(
615694
respx_mock: MockRouter,
616695
httpx_client: httpx.AsyncClient,

0 commit comments

Comments
 (0)