Skip to content

Commit 5437eb4

Browse files
committed
fix: исправить регрессии webhook и сетевых операций
- исполнять MaxoMethod, возвращённый webhook-хендлером - логировать ID бота при остановке вместо секретного токена - преобразовывать все aiohttp ClientError в MaxBotNetworkError - сбрасывать пользовательский буфер после скачивания до seek - добавить регрессионные тесты для каждого сценария Изменения восстанавливают ответы webhook и ретраи повреждённых payload, исключают утечку токена и гарантируют видимость скачанных данных другим файловым дескрипторам.
1 parent 0e91a90 commit 5437eb4

8 files changed

Lines changed: 88 additions & 3 deletions

File tree

src/maxo/bot/bot.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,7 @@ async def download(
227227
binary_io = destination if destination is not None else io.BytesIO()
228228
async for chunk in stream:
229229
binary_io.write(chunk)
230+
binary_io.flush()
230231
if seek:
231232
binary_io.seek(0)
232233
return binary_io

src/maxo/bot/middlewares/network_error.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from typing import Any
22

3+
from aiohttp import ClientError
34
from unihttp.exceptions import NetworkError, RequestTimeoutError
45
from unihttp.http import HTTPRequest, HTTPResponse
56
from unihttp.middlewares import AsyncHandler, AsyncMiddleware
@@ -19,5 +20,5 @@ async def handle(
1920
return await next_handler(request)
2021
except RequestTimeoutError as error:
2122
raise MaxBotTimeoutError(str(error) or type(error).__name__) from error
22-
except (NetworkError, TimeoutError) as error:
23+
except (ClientError, NetworkError, TimeoutError) as error:
2324
raise to_network_error(error) from error

src/maxo/transport/webhook/engines/base.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from unihttp.serializers.adaptix.serialize import DEFAULT_RETORT
66

77
from maxo import Bot, Dispatcher
8+
from maxo.bot.methods.base import MaxoMethod
89
from maxo.loggers import webhook
910
from maxo.routing.signals import MaxoUpdate
1011
from maxo.serialization import get_retort
@@ -98,7 +99,7 @@ async def handle_request(
9899
webhook.debug("New update: %s", update.update)
99100

100101
self._get_task_tracker(bot).spawn( # type: ignore[unused-awaitable]
101-
self.dispatcher.feed_update(bot=bot, update=update),
102+
self._feed_update(bot=bot, update=update),
102103
)
103104
return self.web.json_response(status_code=200, data={})
104105

@@ -165,3 +166,8 @@ async def _build_webhook_kwargs(
165166
if secret is not None:
166167
kwargs["secret"] = secret
167168
return kwargs
169+
170+
async def _feed_update(self, bot: Bot, update: MaxoUpdate[Any]) -> None:
171+
result = await self.dispatcher.feed_update(bot=bot, update=update)
172+
if isinstance(result, MaxoMethod):
173+
await bot.silent_call_method(method=result)

src/maxo/transport/webhook/engines/single.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,9 +81,10 @@ async def _on_startup(self, app: AppT, *args: Any, **kwargs: Any) -> None:
8181
webhook.info("Starting single-bot webhook engine for bot %s", info.id)
8282

8383
async def _on_shutdown(self, app: AppT, *args: Any, **kwargs: Any) -> None:
84+
bot_id = self.bot.info.id if self.bot.started else "<unknown>"
8485
webhook.info(
8586
"Stopping single-bot webhook engine for bot %s",
86-
self.bot.token,
87+
bot_id,
8788
)
8889
await self._task_tracker.close(timeout=self.shutdown_timeout)
8990

tests/maxo/bot/test_bot.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
from collections.abc import AsyncIterator
2+
from contextlib import asynccontextmanager
3+
from pathlib import Path
4+
from types import SimpleNamespace
15
from unittest.mock import AsyncMock, MagicMock, patch
26

37
import pytest
@@ -198,3 +202,31 @@ async def test_get_my_info_always_hits_network(
198202
await mock_bot.get_my_info()
199203

200204
assert mock_client.call_method.await_count == 2
205+
206+
207+
async def test_download_flushes_buffered_destination_with_seek_false(
208+
tmp_path: Path,
209+
bot: Bot,
210+
) -> None:
211+
async def chunks() -> AsyncIterator[bytes]:
212+
yield b"payload"
213+
214+
@asynccontextmanager
215+
async def stream() -> AsyncIterator[AsyncIterator[bytes]]:
216+
yield chunks()
217+
218+
path = tmp_path / "payload.bin"
219+
response = SimpleNamespace(data=stream())
220+
221+
with (
222+
patch.object(Bot, "call_method_stream", new=AsyncMock(return_value=response)),
223+
path.open("wb") as destination,
224+
):
225+
result = await bot.download(
226+
"https://example.test/file.bin",
227+
destination=destination,
228+
seek=False,
229+
)
230+
231+
assert result is destination
232+
assert path.read_bytes() == b"payload"

tests/maxo/bot/test_network_error_middleware.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from unittest.mock import AsyncMock, MagicMock
22

33
import pytest
4+
from aiohttp import ClientPayloadError
45
from unihttp.exceptions import NetworkError, RequestTimeoutError
56

67
from maxo.bot.middlewares import NetworkErrorMiddleware
@@ -12,6 +13,7 @@
1213
[
1314
(RequestTimeoutError("slow"), MaxBotTimeoutError),
1415
(NetworkError("dns"), MaxBotNetworkError),
16+
(ClientPayloadError("payload"), MaxBotNetworkError),
1517
(TimeoutError("timed out"), MaxBotTimeoutError),
1618
],
1719
)

tests/maxo_webhook/test_base_webhook_engine.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import asyncio
22
from typing import Any
3+
from unittest.mock import AsyncMock, patch
34

45
import pytest
56

67
from maxo import Bot
8+
from maxo.bot.methods.base import MaxoMethod
79
from maxo.routing.signals import MaxoUpdate
810
from maxo.serialization import get_retort
911
from maxo.transport.webhook.engines.base import BaseWebhookEngine
@@ -163,3 +165,19 @@ async def test_engine_lifespan_runs_startup_then_shutdown(
163165
assert engine._is_shutting_down
164166
response = await engine.handle_request(update_request) # type: ignore[unreachable]
165167
assert response["status_code"] == 503
168+
169+
170+
@pytest.mark.asyncio
171+
async def test_engine_executes_method_returned_by_dispatcher(
172+
bot: Bot,
173+
adapter: CapturingAdapter,
174+
update_request: DummyWebRequest,
175+
) -> None:
176+
method: MaxoMethod[object] = MaxoMethod()
177+
engine = EngineProbe(DummyDispatcher(result=method), bot, web=adapter)
178+
179+
with patch.object(bot, "silent_call_method", new_callable=AsyncMock) as call:
180+
await engine.handle_request(update_request)
181+
await asyncio.sleep(0)
182+
183+
call.assert_awaited_once_with(method=method)

tests/maxo_webhook/test_single_bot_engine.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import asyncio
2+
import logging
23

34
import pytest
45

@@ -196,3 +197,26 @@ async def test_foreground_engine_rejects_request_after_shutdown_with_closed_bot_
196197
assert response["status_code"] == 503 # ty:ignore[not-subscriptable]
197198
assert dispatcher.foreground_updates == []
198199
assert dispatcher.foreground_session_closed == []
200+
201+
202+
@pytest.mark.asyncio
203+
async def test_single_bot_engine_logs_bot_id_without_token_on_shutdown(
204+
adapter: CapturingAdapter,
205+
bot_token: str,
206+
caplog: pytest.LogCaptureFixture,
207+
) -> None:
208+
bot = Bot(bot_token, client=TrackableClient(bot_id=42), warming_up=False)
209+
engine = SingleBotEngine(
210+
DummyDispatcher(),
211+
bot,
212+
web=adapter,
213+
route=DummyRoute(),
214+
)
215+
caplog.set_level(logging.INFO, logger="maxo.webhook")
216+
217+
await engine.on_startup(None) # ty:ignore[invalid-argument-type]
218+
caplog.clear()
219+
await engine.on_shutdown(None) # ty:ignore[invalid-argument-type]
220+
221+
assert "Stopping single-bot webhook engine for bot 42" in caplog.text
222+
assert bot_token not in caplog.text

0 commit comments

Comments
 (0)