mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-09 04:40:57 +00:00
fix(runtime): bound tenant resource amplification
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from langbot.pkg.platform.webhook_pusher import WebhookPusher
|
||||
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
def _application(max_inflight_requests: object) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
instance_config=SimpleNamespace(
|
||||
data={
|
||||
'webhooks': {
|
||||
'max_inflight_requests': max_inflight_requests,
|
||||
}
|
||||
}
|
||||
),
|
||||
logger=logging.getLogger(__name__),
|
||||
)
|
||||
|
||||
|
||||
async def test_delivery_admission_never_queues_above_instance_limit():
|
||||
pusher = WebhookPusher(_application(2))
|
||||
release = asyncio.Event()
|
||||
both_started = asyncio.Event()
|
||||
calls = 0
|
||||
active = 0
|
||||
peak_active = 0
|
||||
|
||||
async def fake_push(url: str, payload: dict) -> dict:
|
||||
nonlocal calls, active, peak_active
|
||||
calls += 1
|
||||
active += 1
|
||||
peak_active = max(peak_active, active)
|
||||
if active == 2:
|
||||
both_started.set()
|
||||
try:
|
||||
await release.wait()
|
||||
return {'url': url}
|
||||
finally:
|
||||
active -= 1
|
||||
|
||||
pusher._push_to_webhook = fake_push
|
||||
webhooks = [{'url': f'https://example.invalid/{index}'} for index in range(5)]
|
||||
|
||||
first_delivery = asyncio.create_task(pusher._push_to_webhooks(webhooks, {}))
|
||||
await asyncio.wait_for(both_started.wait(), timeout=1)
|
||||
second_results = await pusher._push_to_webhooks(webhooks, {})
|
||||
release.set()
|
||||
first_results = await first_delivery
|
||||
|
||||
assert len(first_results) == 2
|
||||
assert second_results == []
|
||||
assert calls == 2
|
||||
assert peak_active == 2
|
||||
assert pusher._inflight_requests == 0
|
||||
|
||||
|
||||
async def test_cancelled_delivery_reaps_children_and_releases_slots():
|
||||
pusher = WebhookPusher(_application(1))
|
||||
started = asyncio.Event()
|
||||
never = asyncio.Event()
|
||||
|
||||
async def blocking_push(url: str, payload: dict) -> dict:
|
||||
started.set()
|
||||
await never.wait()
|
||||
return {}
|
||||
|
||||
pusher._push_to_webhook = blocking_push
|
||||
delivery = asyncio.create_task(
|
||||
pusher._push_to_webhooks([{'url': 'https://example.invalid'}], {}),
|
||||
)
|
||||
await asyncio.wait_for(started.wait(), timeout=1)
|
||||
|
||||
delivery.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await delivery
|
||||
|
||||
assert pusher._inflight_requests == 0
|
||||
pusher._push_to_webhook = AsyncMock(return_value={})
|
||||
assert await pusher._push_to_webhooks([{'url': 'https://example.invalid'}], {}) == [{}]
|
||||
|
||||
|
||||
async def test_max_inflight_requests_clamps_config():
|
||||
pusher = WebhookPusher(_application(999999))
|
||||
assert pusher._max_inflight_requests() == 128
|
||||
|
||||
pusher.ap.instance_config.data['webhooks']['max_inflight_requests'] = 0
|
||||
assert pusher._max_inflight_requests() == 1
|
||||
|
||||
pusher.ap.instance_config.data['webhooks']['max_inflight_requests'] = 'invalid'
|
||||
assert pusher._max_inflight_requests() == 16
|
||||
@@ -37,6 +37,7 @@ def _make_adapter(load_return=b'hello', load_side_effect=None):
|
||||
provider.load = AsyncMock(return_value=load_return, side_effect=load_side_effect)
|
||||
storage_mgr = Mock()
|
||||
storage_mgr.storage_provider = provider
|
||||
storage_mgr.load_scoped_object_key = AsyncMock(return_value=load_return, side_effect=load_side_effect)
|
||||
storage_mgr.scoped_prefix.return_value = _UPLOAD_PREFIX
|
||||
storage_mgr.is_scoped_object_key.return_value = True
|
||||
storage_mgr.delete_scoped_object_key = AsyncMock()
|
||||
@@ -94,7 +95,7 @@ async def test_file_uses_octet_stream_fallback():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_components_without_path_or_unknown_type():
|
||||
adapter, _, provider = _make_adapter()
|
||||
adapter, storage_mgr, provider = _make_adapter()
|
||||
chain = [
|
||||
{'type': 'Image', 'path': ''}, # no path
|
||||
{'type': 'Plain', 'path': 'storage://abc/x'}, # not a file component
|
||||
@@ -102,6 +103,7 @@ async def test_skips_components_without_path_or_unknown_type():
|
||||
]
|
||||
await adapter._process_image_components(_make_connection(), chain)
|
||||
provider.load.assert_not_awaited()
|
||||
storage_mgr.load_scoped_object_key.assert_not_awaited()
|
||||
assert 'base64' not in chain[0]
|
||||
assert 'base64' not in chain[1]
|
||||
|
||||
|
||||
@@ -327,7 +327,7 @@ async def test_attachment_key_must_belong_to_connection_upload_scope():
|
||||
storage_mgr = Mock()
|
||||
storage_mgr.scoped_prefix.return_value = 'v1/current/upload_image/'
|
||||
storage_mgr.is_scoped_object_key.return_value = True
|
||||
storage_mgr.storage_provider.load = AsyncMock(return_value=b'image')
|
||||
storage_mgr.load_scoped_object_key = AsyncMock(return_value=b'image')
|
||||
storage_mgr.delete_scoped_object_key = AsyncMock()
|
||||
adapter = WebSocketAdapter.model_construct(
|
||||
ap=Mock(storage_mgr=storage_mgr),
|
||||
@@ -347,6 +347,11 @@ async def test_attachment_key_must_belong_to_connection_upload_scope():
|
||||
'v1/current/upload_image/key.png',
|
||||
expected_owner_type='upload_image',
|
||||
)
|
||||
storage_mgr.load_scoped_object_key.assert_awaited_once_with(
|
||||
connection.execution_context,
|
||||
'v1/current/upload_image/key.png',
|
||||
expected_owner_type='upload_image',
|
||||
)
|
||||
storage_mgr.delete_scoped_object_key.assert_awaited_once_with(
|
||||
connection.execution_context,
|
||||
'v1/current/upload_image/key.png',
|
||||
|
||||
Reference in New Issue
Block a user