test: speed up CI and harden the suite
This commit is contained in:
@@ -352,6 +352,11 @@ class TestProgressFiltering:
|
||||
content="legacy progress-shaped message",
|
||||
metadata={"_progress": True},
|
||||
))
|
||||
await bus.publish_outbound(OutboundMessage(
|
||||
channel="mock",
|
||||
chat_id="chat1",
|
||||
content="processing sentinel",
|
||||
))
|
||||
|
||||
task = asyncio.create_task(manager._dispatch_outbound())
|
||||
try:
|
||||
@@ -366,7 +371,9 @@ class TestProgressFiltering:
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
assert manager.channels["mock"]._send_mock.await_count == 0
|
||||
send_mock = manager.channels["mock"]._send_mock
|
||||
assert send_mock.await_count == 1
|
||||
assert send_mock.await_args.args[0].content == "processing sentinel"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_channel_override_can_enable_tool_hints(self, manager, bus):
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
"""Tests for Feishu/Lark domain configuration."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.feishu import FeishuChannel, FeishuConfig
|
||||
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.channels.feishu import FeishuChannel
|
||||
|
||||
|
||||
|
||||
@@ -629,7 +629,9 @@ async def test_send_delta_stream_end_treats_not_modified_as_success() -> None:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_delta_stream_end_does_not_fallback_on_network_timeout() -> None:
|
||||
async def test_send_delta_stream_end_does_not_fallback_on_network_timeout(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""TimedOut during HTML edit should propagate, never fall back to plain text."""
|
||||
from telegram.error import TimedOut
|
||||
|
||||
@@ -638,6 +640,7 @@ async def test_send_delta_stream_end_does_not_fallback_on_network_timeout() -> N
|
||||
MessageBus(),
|
||||
)
|
||||
channel._app = _FakeApp(lambda: None)
|
||||
monkeypatch.setattr("nanobot.channels.telegram._SEND_RETRY_BASE_DELAY", 0)
|
||||
# _call_with_retry retries TimedOut up to 3 times, so the mock will be called
|
||||
# multiple times – but all calls must be with parse_mode="HTML" (no plain fallback).
|
||||
channel._app.bot.edit_message_text = AsyncMock(side_effect=TimedOut("network timeout"))
|
||||
@@ -650,6 +653,7 @@ async def test_send_delta_stream_end_does_not_fallback_on_network_timeout() -> N
|
||||
# no plain-text fallback call should have been made.
|
||||
for call in channel._app.bot.edit_message_text.call_args_list:
|
||||
assert call.kwargs.get("parse_mode") == "HTML"
|
||||
assert channel._app.bot.edit_message_text.await_count == 3
|
||||
# Buffer should still be present (not cleaned up on error)
|
||||
assert "123" in channel._stream_bufs
|
||||
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
"""Unit and lightweight integration tests for the WebSocket channel."""
|
||||
|
||||
import asyncio
|
||||
import functools
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import websockets
|
||||
from websockets.exceptions import ConnectionClosed
|
||||
from websockets.frames import Close
|
||||
from ws_test_client import http_get as _http_get
|
||||
|
||||
from nanobot.bus.events import OUTBOUND_META_AGENT_UI, OutboundMessage
|
||||
from nanobot.bus.outbound_events import (
|
||||
@@ -169,13 +168,6 @@ def isolate_webui_workspace_state(tmp_path, monkeypatch) -> None:
|
||||
)
|
||||
|
||||
|
||||
async def _http_get(url: str, headers: dict[str, str] | None = None) -> httpx.Response:
|
||||
"""Run GET in a thread to avoid blocking the asyncio loop shared with websockets."""
|
||||
return await asyncio.to_thread(
|
||||
functools.partial(httpx.get, url, headers=headers or {}, timeout=5.0, trust_env=False)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_session_updated_broadcasts_to_other_webui_connections(bus) -> None:
|
||||
class Conn:
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""End-to-end tests for the embedded webui's HTTP routes on the WebSocket channel."""
|
||||
|
||||
import asyncio
|
||||
import functools
|
||||
import json
|
||||
import random
|
||||
import socket
|
||||
@@ -12,8 +11,9 @@ from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from urllib.parse import quote, urlencode
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from ws_test_client import InProcessHttpChannel
|
||||
from ws_test_client import http_get as _http_get
|
||||
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.channels.base import BaseChannel
|
||||
@@ -134,7 +134,7 @@ def _ch(
|
||||
local_trigger_pending_ids=local_trigger_pending_ids,
|
||||
channel_feature_action=channel_feature_action,
|
||||
)
|
||||
return WebSocketChannel(cfg, bus, gateway=gateway)
|
||||
return InProcessHttpChannel(cfg, bus, gateway=gateway)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
@@ -144,14 +144,6 @@ def bus() -> MagicMock:
|
||||
return b
|
||||
|
||||
|
||||
async def _http_get(
|
||||
url: str, headers: dict[str, str] | None = None
|
||||
) -> httpx.Response:
|
||||
return await asyncio.to_thread(
|
||||
functools.partial(httpx.get, url, headers=headers or {}, timeout=5.0, trust_env=False)
|
||||
)
|
||||
|
||||
|
||||
def _seed_session(workspace: Path, key: str = "websocket:test") -> SessionManager:
|
||||
sm = SessionManager(workspace)
|
||||
s = Session(key=key)
|
||||
@@ -211,7 +203,6 @@ async def test_bootstrap_returns_token_for_localhost(
|
||||
maxMessageBytes=1_048_576,
|
||||
)
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
resp = await _http_get("http://127.0.0.1:29901/webui/bootstrap")
|
||||
assert resp.status_code == 200
|
||||
@@ -248,7 +239,6 @@ async def test_sessions_routes_require_bearer_token(
|
||||
sm = _seed_session(tmp_path, key="websocket:abc")
|
||||
channel = _ch(bus, session_manager=sm, port=29902)
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
# Unauthenticated → 401.
|
||||
deny = await _http_get("http://127.0.0.1:29902/api/sessions")
|
||||
@@ -324,7 +314,6 @@ async def test_session_automations_route_filters_by_webui_session(
|
||||
port=29914,
|
||||
)
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
deny = await _http_get(
|
||||
"http://127.0.0.1:29914/api/sessions/websocket:abc/automations"
|
||||
@@ -381,7 +370,6 @@ async def test_session_automations_route_ignores_unified_owner(
|
||||
port=29917,
|
||||
)
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
token = channel.gateway.tokens.issue_api_token(300)
|
||||
auth = {"Authorization": f"Bearer {token}"}
|
||||
@@ -427,7 +415,6 @@ async def test_session_automations_route_lists_local_triggers(
|
||||
port=port,
|
||||
)
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
token = channel.gateway.tokens.issue_api_token(300)
|
||||
auth = {"Authorization": f"Bearer {token}"}
|
||||
@@ -487,7 +474,6 @@ async def test_webui_skills_route_requires_token_and_hides_paths(
|
||||
port=29920,
|
||||
)
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
deny = await _http_get("http://127.0.0.1:29920/api/webui/skills")
|
||||
assert deny.status_code == 401
|
||||
@@ -585,7 +571,6 @@ async def test_cli_apps_routes_require_token_and_return_payload(
|
||||
)
|
||||
channel = _ch(bus, session_manager=_seed_session(tmp_path), port=29912)
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
deny = await _http_get("http://127.0.0.1:29912/api/settings/cli-apps")
|
||||
assert deny.status_code == 401
|
||||
@@ -621,7 +606,6 @@ async def test_nanobot_feature_routes_require_token_and_enable(
|
||||
_stub_matrix_feature(monkeypatch, config_path, channels=["matrix", "websocket"])
|
||||
channel = _ch(bus, session_manager=_seed_session(tmp_path), port=29916)
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
deny = await _http_get("http://127.0.0.1:29916/api/settings/nanobot-features")
|
||||
assert deny.status_code == 401
|
||||
@@ -1517,7 +1501,6 @@ async def test_cli_apps_catalog_does_not_block_other_webui_http_routes(
|
||||
monkeypatch.setattr("nanobot.webui.settings_routes.cli_apps_payload", slow_payload)
|
||||
channel = _ch(bus, session_manager=_seed_session(tmp_path), port=29935)
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
token = channel.gateway.tokens.issue_api_token(300)
|
||||
auth = {"Authorization": f"Bearer {token}"}
|
||||
@@ -1558,7 +1541,6 @@ async def test_cli_apps_route_supports_installed_only_payload(
|
||||
monkeypatch.setattr("nanobot.webui.settings_routes.cli_apps_payload", payload)
|
||||
channel = _ch(bus, session_manager=_seed_session(tmp_path), port=29936)
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
token = channel.gateway.tokens.issue_api_token(300)
|
||||
auth = {"Authorization": f"Bearer {token}"}
|
||||
@@ -1651,7 +1633,6 @@ async def test_mcp_presets_routes_require_token_and_return_payload(
|
||||
)
|
||||
channel = _ch(bus, session_manager=_seed_session(tmp_path), port=29913)
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
deny = await _http_get("http://127.0.0.1:29913/api/settings/mcp-presets")
|
||||
assert deny.status_code == 401
|
||||
@@ -1743,7 +1724,6 @@ async def test_sessions_list_only_returns_websocket_sessions_by_default(
|
||||
)
|
||||
channel = _ch(bus, session_manager=sm, port=29906)
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
token = channel.gateway.tokens.issue_api_token(300)
|
||||
auth = {"Authorization": f"Bearer {token}"}
|
||||
@@ -1769,7 +1749,6 @@ async def test_webui_sidebar_state_routes_are_config_dir_scoped(
|
||||
sm = _seed_session(tmp_path, key="websocket:sidebar")
|
||||
channel = _ch(bus, session_manager=sm, port=29911)
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
token = channel.gateway.tokens.issue_api_token(300)
|
||||
auth = {"Authorization": f"Bearer {token}"}
|
||||
@@ -1820,7 +1799,6 @@ async def test_session_delete_removes_file(
|
||||
append_transcript_object("websocket:doomed", {"event": "user", "chat_id": "doomed", "text": "x"})
|
||||
channel = _ch(bus, session_manager=sm, port=29903)
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
token = channel.gateway.tokens.issue_api_token(300)
|
||||
auth = {"Authorization": f"Bearer {token}"}
|
||||
@@ -1900,7 +1878,6 @@ async def test_webui_automations_route_lists_all_jobs_and_allows_user_actions(
|
||||
port=port,
|
||||
)
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
deny = await _http_get(f"{base_url}/api/webui/automations")
|
||||
assert deny.status_code == 401, deny.text
|
||||
@@ -2115,7 +2092,6 @@ async def test_webui_automations_route_manages_local_triggers(
|
||||
port=port,
|
||||
)
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
token = channel.gateway.tokens.issue_api_token(300)
|
||||
auth = {"Authorization": f"Bearer {token}"}
|
||||
@@ -2193,7 +2169,6 @@ async def test_session_delete_blocks_when_bound_automation_exists(
|
||||
)
|
||||
channel = _ch(bus, session_manager=sm, cron_service=cron, port=29915)
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
token = channel.gateway.tokens.issue_api_token(300)
|
||||
auth = {"Authorization": f"Bearer {token}"}
|
||||
@@ -2238,7 +2213,6 @@ async def test_session_delete_blocks_and_cascades_local_triggers(
|
||||
port=port,
|
||||
)
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
token = channel.gateway.tokens.issue_api_token(300)
|
||||
auth = {"Authorization": f"Bearer {token}"}
|
||||
@@ -2287,7 +2261,6 @@ async def test_session_delete_can_cascade_bound_automations(
|
||||
)
|
||||
channel = _ch(bus, session_manager=sm, cron_service=cron, port=29916)
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
token = channel.gateway.tokens.issue_api_token(300)
|
||||
auth = {"Authorization": f"Bearer {token}"}
|
||||
@@ -2330,7 +2303,6 @@ async def test_session_delete_blocks_origin_automation_when_unified_enabled(
|
||||
port=29918,
|
||||
)
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
token = channel.gateway.tokens.issue_api_token(300)
|
||||
auth = {"Authorization": f"Bearer {token}"}
|
||||
@@ -2362,7 +2334,6 @@ async def test_session_routes_accept_percent_encoded_websocket_keys(
|
||||
sm = _seed_session(tmp_path, key="websocket:encoded-key")
|
||||
channel = _ch(bus, session_manager=sm, port=29910)
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
token = channel.gateway.tokens.issue_api_token(300)
|
||||
auth = {"Authorization": f"Bearer {token}"}
|
||||
@@ -2406,7 +2377,6 @@ async def test_session_messages_hide_persisted_runtime_context(
|
||||
sm.save(session)
|
||||
channel = _ch(bus, session_manager=sm, port=29919)
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
token = channel.gateway.tokens.issue_api_token(300)
|
||||
response = await _http_get(
|
||||
@@ -2459,7 +2429,6 @@ async def test_webui_thread_resigns_assistant_media_urls(
|
||||
|
||||
channel = _ch(bus, port=29914)
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
token = channel.gateway.tokens.issue_api_token(300)
|
||||
auth = {"Authorization": f"Bearer {token}"}
|
||||
@@ -2497,7 +2466,6 @@ async def test_session_routes_reject_non_websocket_keys(
|
||||
)
|
||||
channel = _ch(bus, session_manager=sm, port=29909)
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
token = channel.gateway.tokens.issue_api_token(300)
|
||||
auth = {"Authorization": f"Bearer {token}"}
|
||||
@@ -2530,7 +2498,6 @@ async def test_session_routes_reject_invalid_key(
|
||||
sm = _seed_session(tmp_path)
|
||||
channel = _ch(bus, session_manager=sm, port=29904)
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
token = channel.gateway.tokens.issue_api_token(300)
|
||||
auth = {"Authorization": f"Bearer {token}"}
|
||||
@@ -2558,7 +2525,6 @@ async def test_static_serves_index_when_dist_present(
|
||||
sm = _seed_session(tmp_path / "ws_state")
|
||||
channel = _ch(bus, session_manager=sm, static_dist_path=dist, port=29905)
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
# Bare ``GET /`` is a browser opening the app: it must return the SPA
|
||||
# index.html, not the WS-upgrade handler's 401/426.
|
||||
@@ -2588,7 +2554,6 @@ async def test_static_rejects_path_traversal(
|
||||
secret.write_text("classified")
|
||||
channel = _ch(bus, static_dist_path=dist, port=29906)
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
resp = await _http_get("http://127.0.0.1:29906/../secret.txt")
|
||||
# Normalized by httpx into /secret.txt → falls back to index.html, not 'classified'.
|
||||
@@ -2602,7 +2567,6 @@ async def test_static_rejects_path_traversal(
|
||||
async def test_unknown_route_returns_404(bus: MagicMock) -> None:
|
||||
channel = _ch(bus, port=29907)
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
resp = await _http_get("http://127.0.0.1:29907/api/unknown")
|
||||
assert resp.status_code == 404
|
||||
|
||||
@@ -60,7 +60,6 @@ def bus() -> MagicMock:
|
||||
async def test_ready_event_fields(bus: MagicMock) -> None:
|
||||
ch = _ch(bus, 29901)
|
||||
t = asyncio.create_task(ch.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
async with WsTestClient("ws://127.0.0.1:29901/", client_id="c1") as c:
|
||||
r = await c.recv_ready()
|
||||
@@ -76,7 +75,6 @@ async def test_ready_event_fields(bus: MagicMock) -> None:
|
||||
async def test_anonymous_client_gets_generated_id(bus: MagicMock) -> None:
|
||||
ch = _ch(bus, 29902)
|
||||
t = asyncio.create_task(ch.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
async with WsTestClient("ws://127.0.0.1:29902/", client_id="") as c:
|
||||
r = await c.recv_ready()
|
||||
@@ -90,7 +88,6 @@ async def test_anonymous_client_gets_generated_id(bus: MagicMock) -> None:
|
||||
async def test_each_connection_unique_chat_id(bus: MagicMock) -> None:
|
||||
ch = _ch(bus, 29903)
|
||||
t = asyncio.create_task(ch.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
async with WsTestClient("ws://127.0.0.1:29903/", client_id="a") as c1:
|
||||
async with WsTestClient("ws://127.0.0.1:29903/", client_id="b") as c2:
|
||||
@@ -107,7 +104,6 @@ async def test_each_connection_unique_chat_id(bus: MagicMock) -> None:
|
||||
async def test_plain_text(bus: MagicMock) -> None:
|
||||
ch = _ch(bus, 29904)
|
||||
t = asyncio.create_task(ch.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
async with WsTestClient("ws://127.0.0.1:29904/", client_id="p") as c:
|
||||
await c.recv_ready()
|
||||
@@ -125,7 +121,6 @@ async def test_plain_text(bus: MagicMock) -> None:
|
||||
async def test_json_content_field(bus: MagicMock) -> None:
|
||||
ch = _ch(bus, 29905)
|
||||
t = asyncio.create_task(ch.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
async with WsTestClient("ws://127.0.0.1:29905/", client_id="j") as c:
|
||||
await c.recv_ready()
|
||||
@@ -141,7 +136,6 @@ async def test_json_content_field(bus: MagicMock) -> None:
|
||||
async def test_json_text_and_message_fields(bus: MagicMock) -> None:
|
||||
ch = _ch(bus, 29906)
|
||||
t = asyncio.create_task(ch.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
async with WsTestClient("ws://127.0.0.1:29906/", client_id="x") as c:
|
||||
await c.recv_ready()
|
||||
@@ -160,7 +154,6 @@ async def test_json_text_and_message_fields(bus: MagicMock) -> None:
|
||||
async def test_empty_payload_ignored(bus: MagicMock) -> None:
|
||||
ch = _ch(bus, 29907)
|
||||
t = asyncio.create_task(ch.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
async with WsTestClient("ws://127.0.0.1:29907/", client_id="e") as c:
|
||||
await c.recv_ready()
|
||||
@@ -177,7 +170,6 @@ async def test_empty_payload_ignored(bus: MagicMock) -> None:
|
||||
async def test_messages_preserve_order(bus: MagicMock) -> None:
|
||||
ch = _ch(bus, 29908)
|
||||
t = asyncio.create_task(ch.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
async with WsTestClient("ws://127.0.0.1:29908/", client_id="o") as c:
|
||||
await c.recv_ready()
|
||||
@@ -198,7 +190,6 @@ async def test_messages_preserve_order(bus: MagicMock) -> None:
|
||||
async def test_server_send_message(bus: MagicMock) -> None:
|
||||
ch = _ch(bus, 29909)
|
||||
t = asyncio.create_task(ch.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
async with WsTestClient("ws://127.0.0.1:29909/", client_id="r") as c:
|
||||
ready = await c.recv_ready()
|
||||
@@ -217,7 +208,6 @@ async def test_server_send_tags_tool_hint_with_kind(bus: MagicMock) -> None:
|
||||
"""Tool-hint progress events surface as ``kind: "tool_hint"``."""
|
||||
ch = _ch(bus, 29919)
|
||||
t = asyncio.create_task(ch.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
async with WsTestClient("ws://127.0.0.1:29919/", client_id="h") as c:
|
||||
ready = await c.recv_ready()
|
||||
@@ -255,7 +245,6 @@ async def test_server_send_tags_tool_hint_with_kind(bus: MagicMock) -> None:
|
||||
async def test_server_send_with_media_and_reply(bus: MagicMock) -> None:
|
||||
ch = _ch(bus, 29910)
|
||||
t = asyncio.create_task(ch.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
async with WsTestClient("ws://127.0.0.1:29910/", client_id="m") as c:
|
||||
ready = await c.recv_ready()
|
||||
@@ -279,7 +268,6 @@ async def test_server_send_with_media_and_reply(bus: MagicMock) -> None:
|
||||
async def test_streaming_deltas_and_end(bus: MagicMock) -> None:
|
||||
ch = _ch(bus, 29911, streaming=True)
|
||||
t = asyncio.create_task(ch.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
async with WsTestClient("ws://127.0.0.1:29911/", client_id="s") as c:
|
||||
cid = (await c.recv_ready()).chat_id
|
||||
@@ -301,7 +289,6 @@ async def test_streaming_deltas_and_end(bus: MagicMock) -> None:
|
||||
async def test_interleaved_streams(bus: MagicMock) -> None:
|
||||
ch = _ch(bus, 29912, streaming=True)
|
||||
t = asyncio.create_task(ch.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
async with WsTestClient("ws://127.0.0.1:29912/", client_id="i") as c:
|
||||
cid = (await c.recv_ready()).chat_id
|
||||
@@ -329,7 +316,6 @@ async def test_interleaved_streams(bus: MagicMock) -> None:
|
||||
async def test_independent_sessions(bus: MagicMock) -> None:
|
||||
ch = _ch(bus, 29913)
|
||||
t = asyncio.create_task(ch.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
async with WsTestClient("ws://127.0.0.1:29913/", client_id="u1") as c1:
|
||||
async with WsTestClient("ws://127.0.0.1:29913/", client_id="u2") as c2:
|
||||
@@ -351,7 +337,6 @@ async def test_independent_sessions(bus: MagicMock) -> None:
|
||||
async def test_disconnected_client_cleanup(bus: MagicMock) -> None:
|
||||
ch = _ch(bus, 29914)
|
||||
t = asyncio.create_task(ch.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
async with WsTestClient("ws://127.0.0.1:29914/", client_id="tmp") as c:
|
||||
chat_id = (await c.recv_ready()).chat_id
|
||||
@@ -373,7 +358,6 @@ async def test_disconnected_client_cleanup(bus: MagicMock) -> None:
|
||||
async def test_static_token_accepted(bus: MagicMock) -> None:
|
||||
ch = _ch(bus, 29915, token="secret")
|
||||
t = asyncio.create_task(ch.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
async with WsTestClient("ws://127.0.0.1:29915/", client_id="a", token="secret") as c:
|
||||
assert (await c.recv_ready()).client_id == "a"
|
||||
@@ -386,7 +370,6 @@ async def test_static_token_accepted(bus: MagicMock) -> None:
|
||||
async def test_static_token_rejected(bus: MagicMock) -> None:
|
||||
ch = _ch(bus, 29916, token="correct")
|
||||
t = asyncio.create_task(ch.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
with pytest.raises(websockets.exceptions.InvalidStatus) as exc:
|
||||
async with WsTestClient("ws://127.0.0.1:29916/", client_id="b", token="wrong"):
|
||||
@@ -403,7 +386,6 @@ async def test_token_issue_full_flow(bus: MagicMock) -> None:
|
||||
tokenIssuePath="/auth/token", tokenIssueSecret="s",
|
||||
websocketRequiresToken=True)
|
||||
t = asyncio.create_task(ch.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
# no secret -> 401
|
||||
_, status = await issue_token(port=29917, issue_path="/auth/token")
|
||||
@@ -439,7 +421,6 @@ async def test_token_issue_full_flow(bus: MagicMock) -> None:
|
||||
async def test_custom_path(bus: MagicMock) -> None:
|
||||
ch = _ch(bus, 29918, path="/my-chat")
|
||||
t = asyncio.create_task(ch.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
async with WsTestClient("ws://127.0.0.1:29918/my-chat", client_id="p") as c:
|
||||
assert (await c.recv_ready()).event == "ready"
|
||||
@@ -452,7 +433,6 @@ async def test_custom_path(bus: MagicMock) -> None:
|
||||
async def test_wrong_path_404(bus: MagicMock) -> None:
|
||||
ch = _ch(bus, 29919, path="/ws")
|
||||
t = asyncio.create_task(ch.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
with pytest.raises(websockets.exceptions.InvalidStatus) as exc:
|
||||
async with WsTestClient("ws://127.0.0.1:29919/wrong", client_id="x"):
|
||||
@@ -467,7 +447,6 @@ async def test_wrong_path_404(bus: MagicMock) -> None:
|
||||
async def test_trailing_slash_normalized(bus: MagicMock) -> None:
|
||||
ch = _ch(bus, 29920, path="/ws")
|
||||
t = asyncio.create_task(ch.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
async with WsTestClient("ws://127.0.0.1:29920/ws/", client_id="s") as c:
|
||||
assert (await c.recv_ready()).event == "ready"
|
||||
@@ -483,7 +462,6 @@ async def test_trailing_slash_normalized(bus: MagicMock) -> None:
|
||||
async def test_large_message(bus: MagicMock) -> None:
|
||||
ch = _ch(bus, 29921)
|
||||
t = asyncio.create_task(ch.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
async with WsTestClient("ws://127.0.0.1:29921/", client_id="big") as c:
|
||||
await c.recv_ready()
|
||||
@@ -500,7 +478,6 @@ async def test_large_message(bus: MagicMock) -> None:
|
||||
async def test_unicode_roundtrip(bus: MagicMock) -> None:
|
||||
ch = _ch(bus, 29922)
|
||||
t = asyncio.create_task(ch.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
async with WsTestClient("ws://127.0.0.1:29922/", client_id="u") as c:
|
||||
ready = await c.recv_ready()
|
||||
@@ -521,7 +498,6 @@ async def test_unicode_roundtrip(bus: MagicMock) -> None:
|
||||
async def test_rapid_fire(bus: MagicMock) -> None:
|
||||
ch = _ch(bus, 29923)
|
||||
t = asyncio.create_task(ch.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
async with WsTestClient("ws://127.0.0.1:29923/", client_id="r") as c:
|
||||
ready = await c.recv_ready()
|
||||
@@ -544,7 +520,6 @@ async def test_rapid_fire(bus: MagicMock) -> None:
|
||||
async def test_invalid_json_as_plain_text(bus: MagicMock) -> None:
|
||||
ch = _ch(bus, 29924)
|
||||
t = asyncio.create_task(ch.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
async with WsTestClient("ws://127.0.0.1:29924/", client_id="j") as c:
|
||||
await c.recv_ready()
|
||||
|
||||
@@ -11,15 +11,15 @@ These tests cover the two halves end-to-end plus the adversarial edges
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import functools
|
||||
import hashlib
|
||||
import hmac
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from ws_test_client import InProcessHttpChannel
|
||||
from ws_test_client import http_get as _http_get
|
||||
|
||||
from nanobot.channels.websocket import WebSocketChannel, WebSocketConfig
|
||||
from nanobot.session.manager import Session, SessionManager
|
||||
@@ -67,7 +67,7 @@ def _ch(
|
||||
runtime_surface="browser",
|
||||
runtime_capabilities_overrides=None,
|
||||
)
|
||||
return WebSocketChannel(cfg, bus, gateway=gateway)
|
||||
return InProcessHttpChannel(cfg, bus, gateway=gateway)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
@@ -86,14 +86,6 @@ def _fake_media_dir(root: Path):
|
||||
return inner
|
||||
|
||||
|
||||
async def _http_get(
|
||||
url: str, headers: dict[str, str] | None = None
|
||||
) -> httpx.Response:
|
||||
return await asyncio.to_thread(
|
||||
functools.partial(httpx.get, url, headers=headers or {}, timeout=5.0, trust_env=False)
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# gateway.media.sign_media_path: the URL minter
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -223,7 +215,6 @@ async def test_media_route_serves_signed_file(
|
||||
url_path = channel.gateway.media.sign_media_path(target)
|
||||
assert url_path is not None
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
resp = await _http_get(f"http://127.0.0.1:29920{url_path}")
|
||||
finally:
|
||||
@@ -256,7 +247,6 @@ async def test_media_route_serves_video_byte_ranges(
|
||||
url_path = channel.gateway.media.sign_media_path(target)
|
||||
assert url_path is not None
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
resp = await _http_get(
|
||||
f"http://127.0.0.1:29927{url_path}",
|
||||
@@ -288,7 +278,6 @@ async def test_media_route_serves_suffix_video_byte_ranges(
|
||||
url_path = channel.gateway.media.sign_media_path(target)
|
||||
assert url_path is not None
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
resp = await _http_get(
|
||||
f"http://127.0.0.1:29928{url_path}",
|
||||
@@ -317,7 +306,6 @@ async def test_media_route_rejects_unsatisfiable_byte_range(
|
||||
url_path = channel.gateway.media.sign_media_path(target)
|
||||
assert url_path is not None
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
resp = await _http_get(
|
||||
f"http://127.0.0.1:29929{url_path}",
|
||||
@@ -357,7 +345,6 @@ async def test_media_route_rejects_bad_signature(
|
||||
forged = f"/api/media/{b64url_encode(forged_mac)}/{payload}"
|
||||
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
resp = await _http_get(f"http://127.0.0.1:29921{forged}")
|
||||
finally:
|
||||
@@ -391,7 +378,6 @@ async def test_media_route_rejects_path_traversal_payload(
|
||||
|
||||
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
resp = await _http_get(f"http://127.0.0.1:29922{url}")
|
||||
finally:
|
||||
@@ -418,7 +404,6 @@ async def test_media_route_404s_missing_file(
|
||||
assert url_path is not None
|
||||
target.unlink() # the file vanishes between signing and fetching
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
resp = await _http_get(f"http://127.0.0.1:29923{url_path}")
|
||||
finally:
|
||||
@@ -448,7 +433,6 @@ async def test_media_route_degrades_non_image_to_octet_stream(
|
||||
).digest()[:16]
|
||||
url = f"/api/media/{b64url_encode(mac)}/{payload}"
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
resp = await _http_get(f"http://127.0.0.1:29924{url}")
|
||||
finally:
|
||||
@@ -476,7 +460,6 @@ async def test_media_route_serves_svg_with_strict_csp(
|
||||
url_path = channel.gateway.media.sign_media_path(target)
|
||||
assert url_path is not None
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
resp = await _http_get(f"http://127.0.0.1:29928{url_path}")
|
||||
finally:
|
||||
@@ -515,7 +498,6 @@ async def test_session_messages_exposes_signed_media_urls(
|
||||
channel = _ch(bus, session_manager=sm, port=29925)
|
||||
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
token = channel.gateway.tokens.issue_api_token(300)
|
||||
auth = {"Authorization": f"Bearer {token}"}
|
||||
@@ -559,7 +541,6 @@ async def test_session_messages_skips_vanished_media(
|
||||
channel = _ch(bus, session_manager=sm, port=29926)
|
||||
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
token = channel.gateway.tokens.issue_api_token(300)
|
||||
resp = await _http_get(
|
||||
|
||||
@@ -37,6 +37,23 @@ def _make_channel() -> tuple[WeixinChannel, MessageBus]:
|
||||
return channel, bus
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def no_qr_poll_delay(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Keep QR state-machine tests event-driven without one-second polling sleeps."""
|
||||
real_sleep = asyncio.sleep
|
||||
|
||||
async def yield_to_loop(_delay: float) -> None:
|
||||
await real_sleep(0)
|
||||
|
||||
class AsyncioProxy:
|
||||
sleep = staticmethod(yield_to_loop)
|
||||
|
||||
def __getattr__(self, name: str):
|
||||
return getattr(asyncio, name)
|
||||
|
||||
monkeypatch.setattr(weixin_mod, "asyncio", AsyncioProxy())
|
||||
|
||||
|
||||
def test_make_headers_includes_route_tag_when_configured() -> None:
|
||||
bus = MessageBus()
|
||||
channel = WeixinChannel(
|
||||
@@ -446,7 +463,9 @@ async def test_poll_once_pauses_session_on_expired_errcode() -> None:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_qr_login_refreshes_expired_qr_and_then_succeeds() -> None:
|
||||
async def test_qr_login_refreshes_expired_qr_and_then_succeeds(
|
||||
no_qr_poll_delay,
|
||||
) -> None:
|
||||
channel, _bus = _make_channel()
|
||||
channel._running = True
|
||||
channel._save_state = lambda: None
|
||||
@@ -478,7 +497,9 @@ async def test_qr_login_refreshes_expired_qr_and_then_succeeds() -> None:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_qr_login_returns_false_after_too_many_expired_qr_codes() -> None:
|
||||
async def test_qr_login_returns_false_after_too_many_expired_qr_codes(
|
||||
no_qr_poll_delay,
|
||||
) -> None:
|
||||
channel, _bus = _make_channel()
|
||||
channel._running = True
|
||||
channel._print_qr_code = lambda url: None
|
||||
@@ -505,7 +526,9 @@ async def test_qr_login_returns_false_after_too_many_expired_qr_codes() -> None:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_qr_login_switches_polling_base_url_on_redirect_status() -> None:
|
||||
async def test_qr_login_switches_polling_base_url_on_redirect_status(
|
||||
no_qr_poll_delay,
|
||||
) -> None:
|
||||
channel, _bus = _make_channel()
|
||||
channel._running = True
|
||||
channel._save_state = lambda: None
|
||||
@@ -537,7 +560,9 @@ async def test_qr_login_switches_polling_base_url_on_redirect_status() -> None:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_qr_login_redirect_without_host_keeps_current_polling_base_url() -> None:
|
||||
async def test_qr_login_redirect_without_host_keeps_current_polling_base_url(
|
||||
no_qr_poll_delay,
|
||||
) -> None:
|
||||
channel, _bus = _make_channel()
|
||||
channel._running = True
|
||||
channel._save_state = lambda: None
|
||||
@@ -569,7 +594,9 @@ async def test_qr_login_redirect_without_host_keeps_current_polling_base_url() -
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_qr_login_resets_redirect_base_url_after_qr_refresh() -> None:
|
||||
async def test_qr_login_resets_redirect_base_url_after_qr_refresh(
|
||||
no_qr_poll_delay,
|
||||
) -> None:
|
||||
channel, _bus = _make_channel()
|
||||
channel._running = True
|
||||
channel._save_state = lambda: None
|
||||
@@ -859,7 +886,9 @@ async def test_get_typing_ticket_failure_uses_backoff_and_cached_ticket(monkeypa
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_qr_login_treats_temporary_connect_error_as_wait_and_recovers() -> None:
|
||||
async def test_qr_login_treats_temporary_connect_error_as_wait_and_recovers(
|
||||
no_qr_poll_delay,
|
||||
) -> None:
|
||||
channel, _bus = _make_channel()
|
||||
channel._running = True
|
||||
channel._save_state = lambda: None
|
||||
@@ -887,7 +916,9 @@ async def test_qr_login_treats_temporary_connect_error_as_wait_and_recovers() ->
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_qr_login_treats_5xx_gateway_response_error_as_wait_and_recovers() -> None:
|
||||
async def test_qr_login_treats_5xx_gateway_response_error_as_wait_and_recovers(
|
||||
no_qr_poll_delay,
|
||||
) -> None:
|
||||
channel, _bus = _make_channel()
|
||||
channel._running = True
|
||||
channel._save_state = lambda: None
|
||||
|
||||
@@ -21,6 +21,58 @@ from typing import Any
|
||||
import httpx
|
||||
import websockets
|
||||
from websockets.asyncio.client import ClientConnection
|
||||
from websockets.datastructures import Headers
|
||||
from websockets.http11 import Request as WsRequest
|
||||
|
||||
from nanobot.channels.websocket import WebSocketChannel
|
||||
from nanobot.webui.http_utils import http_response
|
||||
|
||||
_IN_PROCESS_HTTP_CHANNELS: dict[int, InProcessHttpChannel] = {}
|
||||
|
||||
|
||||
class _HttpConnection:
|
||||
remote_address = ("127.0.0.1", 12345)
|
||||
|
||||
@staticmethod
|
||||
def respond(status: int, body: str) -> object:
|
||||
return http_response(body.encode("utf-8"), status=status)
|
||||
|
||||
|
||||
class InProcessHttpChannel(WebSocketChannel):
|
||||
"""Exercise gateway HTTP dispatch without booting a socket per route test."""
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self._test_stop_event = asyncio.Event()
|
||||
_IN_PROCESS_HTTP_CHANNELS[self.config.port] = self
|
||||
|
||||
async def start(self) -> None:
|
||||
self._running = True
|
||||
await self._test_stop_event.wait()
|
||||
self._running = False
|
||||
|
||||
async def stop(self) -> None:
|
||||
self._test_stop_event.set()
|
||||
if _IN_PROCESS_HTTP_CHANNELS.get(self.config.port) is self:
|
||||
_IN_PROCESS_HTTP_CHANNELS.pop(self.config.port, None)
|
||||
|
||||
|
||||
async def _in_process_http_get(
|
||||
channel: InProcessHttpChannel,
|
||||
request: httpx.Request,
|
||||
) -> httpx.Response:
|
||||
ws_request = WsRequest(
|
||||
request.url.raw_path.decode("ascii"),
|
||||
Headers(list(request.headers.multi_items())),
|
||||
)
|
||||
response = await channel._dispatch_http(_HttpConnection(), ws_request)
|
||||
assert response is not None
|
||||
return httpx.Response(
|
||||
response.status_code,
|
||||
headers=list(response.headers.raw_items()),
|
||||
content=response.body,
|
||||
request=request,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -89,11 +141,19 @@ class WsTestClient:
|
||||
self._extra_headers = extra_headers
|
||||
self._ws: ClientConnection | None = None
|
||||
|
||||
async def connect(self) -> None:
|
||||
self._ws = await websockets.connect(
|
||||
self._uri,
|
||||
additional_headers=self._extra_headers,
|
||||
)
|
||||
async def connect(self, timeout: float = 2.0) -> None:
|
||||
deadline = asyncio.get_running_loop().time() + timeout
|
||||
while True:
|
||||
try:
|
||||
self._ws = await websockets.connect(
|
||||
self._uri,
|
||||
additional_headers=self._extra_headers,
|
||||
)
|
||||
return
|
||||
except OSError:
|
||||
if asyncio.get_running_loop().time() >= deadline:
|
||||
raise
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._ws:
|
||||
@@ -186,6 +246,31 @@ class WsTestClient:
|
||||
# -- Token issuance helpers -----------------------------------------------
|
||||
|
||||
|
||||
async def http_get(
|
||||
url: str,
|
||||
headers: dict[str, str] | None = None,
|
||||
) -> httpx.Response:
|
||||
"""GET a local test server without loading an unused TLS trust store."""
|
||||
request = httpx.Request("GET", url, headers=headers or {})
|
||||
channel = _IN_PROCESS_HTTP_CHANNELS.get(request.url.port)
|
||||
if channel is not None:
|
||||
return await _in_process_http_get(channel, request)
|
||||
|
||||
deadline = asyncio.get_running_loop().time() + 2.0
|
||||
while True:
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=5.0,
|
||||
trust_env=False,
|
||||
verify=False,
|
||||
) as client:
|
||||
return await client.get(url, headers=headers or {})
|
||||
except httpx.ConnectError:
|
||||
if asyncio.get_running_loop().time() >= deadline:
|
||||
raise
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
|
||||
async def issue_token(
|
||||
host: str = "127.0.0.1",
|
||||
port: int = 8765,
|
||||
@@ -201,10 +286,7 @@ async def issue_token(
|
||||
if secret:
|
||||
headers["Authorization"] = f"Bearer {secret}"
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
resp = await loop.run_in_executor(
|
||||
None, lambda: httpx.get(url, headers=headers, timeout=5.0)
|
||||
)
|
||||
resp = await http_get(url, headers)
|
||||
try:
|
||||
data = resp.json()
|
||||
except Exception:
|
||||
|
||||
Reference in New Issue
Block a user