mirror of
https://github.com/open-webui/open-webui.git
synced 2026-08-25 17:21:00 -05:00
Some providers send one very large piece of a streamed answer in a single go: a long reasoning trace, a code execution result, a turn with many tool calls, or a response echo carrying a big tool list. Anything past 128 KB in one line killed the chat mid-answer with a misleading `400, message: Got more than 131072 bytes when reading`. Nothing was rejected upstream, that is our own reader giving up on an oversized line. Open WebUI already had code that assembles lines itself with no such limit, but it only ran when CHAT_STREAM_RESPONSE_CHUNK_MAX_BUFFER_SIZE was set. Unset is the default, and in that case the raw capped reader was used instead, so a default install always broke. That path now always assembles lines, and the setting goes back to being what its name says: an optional cap, off by default. It applies to the Ollama stream as well, since both now share the same reader. The assembly loop only splits once a line actually completes, because the old one re-concatenated and re-split the whole buffer on every network chunk. Without that, allowing long lines would have traded an error for multi-second event loop stalls. | | 20 MB in one line | 200k small lines | | --- | --- | --- | | before | 4249 ms | 27.3 ms | | after | 37 ms | 25.2 ms |
138 lines
4.9 KiB
Python
138 lines
4.9 KiB
Python
"""Shared aiohttp ClientSession pool.
|
|
|
|
Instead of creating a new ClientSession (and TCPConnector) per request,
|
|
callers acquire a long-lived session from this module. The pool manages
|
|
a single TCPConnector with configurable limits, enabling TCP/SSL connection
|
|
reuse, shared DNS cache, and bounded concurrency.
|
|
|
|
All pool parameters are configurable via environment variables:
|
|
- AIOHTTP_POOL_CONNECTIONS (default 100) — max total connections
|
|
- AIOHTTP_POOL_CONNECTIONS_PER_HOST (default 30) — per-host limit
|
|
- AIOHTTP_POOL_DNS_TTL (default 300) — DNS cache TTL in seconds
|
|
|
|
Usage:
|
|
from open_webui.utils.session_pool import get_session, cleanup_response
|
|
|
|
session = await get_session()
|
|
r = await session.request(...)
|
|
# When done with the *response* (not the session):
|
|
await cleanup_response(r)
|
|
|
|
IMPORTANT: Callers must NOT close the shared session. Only the response
|
|
needs cleanup. The session is closed once during application shutdown
|
|
via ``close_session()``.
|
|
"""
|
|
|
|
import logging
|
|
from typing import Optional
|
|
|
|
import aiohttp
|
|
from open_webui.env import (
|
|
AIOHTTP_CLIENT_STREAM_IDLE_TIMEOUT,
|
|
AIOHTTP_CLIENT_TIMEOUT,
|
|
AIOHTTP_POOL_CONNECTIONS,
|
|
AIOHTTP_POOL_CONNECTIONS_PER_HOST,
|
|
AIOHTTP_POOL_DNS_TTL,
|
|
)
|
|
from open_webui.utils.misc import stream_chunks_handler
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
_session: Optional[aiohttp.ClientSession] = None
|
|
|
|
_CLIENT_TIMEOUT = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)
|
|
_CLIENT_STREAM_TIMEOUT = aiohttp.ClientTimeout(
|
|
total=AIOHTTP_CLIENT_TIMEOUT,
|
|
sock_read=AIOHTTP_CLIENT_STREAM_IDLE_TIMEOUT,
|
|
)
|
|
|
|
|
|
def get_client_timeout(stream: bool = False) -> aiohttp.ClientTimeout:
|
|
return _CLIENT_STREAM_TIMEOUT if stream else _CLIENT_TIMEOUT
|
|
|
|
|
|
async def get_session() -> aiohttp.ClientSession:
|
|
"""Return the shared aiohttp ClientSession, creating it lazily."""
|
|
global _session
|
|
if _session is None or _session.closed:
|
|
connector_kwargs = {
|
|
'ttl_dns_cache': AIOHTTP_POOL_DNS_TTL,
|
|
'enable_cleanup_closed': True,
|
|
}
|
|
if AIOHTTP_POOL_CONNECTIONS is not None:
|
|
connector_kwargs['limit'] = AIOHTTP_POOL_CONNECTIONS
|
|
else:
|
|
connector_kwargs['limit'] = 0 # aiohttp: 0 = unlimited
|
|
if AIOHTTP_POOL_CONNECTIONS_PER_HOST is not None:
|
|
connector_kwargs['limit_per_host'] = AIOHTTP_POOL_CONNECTIONS_PER_HOST
|
|
else:
|
|
connector_kwargs['limit_per_host'] = 0 # aiohttp: 0 = unlimited
|
|
connector = aiohttp.TCPConnector(**connector_kwargs)
|
|
timeout = get_client_timeout()
|
|
_session = aiohttp.ClientSession(
|
|
connector=connector,
|
|
timeout=timeout,
|
|
trust_env=True,
|
|
)
|
|
log.info(
|
|
'Created shared aiohttp session pool (limit=%s, per_host=%s, dns_ttl=%d)',
|
|
AIOHTTP_POOL_CONNECTIONS or 'unlimited',
|
|
AIOHTTP_POOL_CONNECTIONS_PER_HOST or 'unlimited',
|
|
AIOHTTP_POOL_DNS_TTL,
|
|
)
|
|
return _session
|
|
|
|
|
|
async def close_session():
|
|
"""Close the shared session. Called during application shutdown."""
|
|
global _session
|
|
if _session and not _session.closed:
|
|
await _session.close()
|
|
log.info('Closed shared aiohttp session pool')
|
|
_session = None
|
|
|
|
|
|
async def cleanup_response(
|
|
response: Optional[aiohttp.ClientResponse],
|
|
session: Optional[aiohttp.ClientSession] = None,
|
|
):
|
|
"""Release and close an aiohttp response, optionally closing the session.
|
|
|
|
When using the shared pool, ``session`` should be ``None`` (the pool
|
|
session is never closed per-request). When a caller creates its own
|
|
one-off session, pass it here to close it after the response.
|
|
"""
|
|
if response:
|
|
if not response.closed:
|
|
# aiohttp 3.9+ made ClientResponse.close() synchronous (returns None).
|
|
# Older versions returned a coroutine. Handle both gracefully.
|
|
result = response.close()
|
|
if result is not None:
|
|
await result
|
|
if session:
|
|
if not session.closed:
|
|
result = session.close()
|
|
if result is not None:
|
|
await result
|
|
|
|
|
|
async def stream_wrapper(response, session=None, passthrough=False):
|
|
"""Wrap a stream to ensure cleanup happens even if streaming is interrupted.
|
|
|
|
This is more reliable than BackgroundTask which may not run if the client
|
|
disconnects. When using the shared pool, ``session`` should be ``None``.
|
|
|
|
``passthrough=True`` yields raw network chunks (iter_any) instead of
|
|
lines: byte-identical output without a buffer scan, slice and copy per
|
|
line. Only for streams no internal consumer parses line-by-line.
|
|
"""
|
|
try:
|
|
if passthrough:
|
|
stream = response.content.iter_any()
|
|
else:
|
|
stream = stream_chunks_handler(response.content)
|
|
async for chunk in stream:
|
|
yield chunk
|
|
finally:
|
|
await cleanup_response(response, session)
|