mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-05 08:07:05 +00:00
fix(proxy): default max_idle_connection_lifetime to 60s on DB URLs (#39134)
* fix(proxy): default max_idle_connection_lifetime to 60s on DB URLs Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(proxy): regenerate schema.d.ts for database_max_idle_connection_lifetime Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(proxy): keep URL-pinned max_idle_connection_lifetime over config value Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: yassin <yassin@berri.ai> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
8bc862f52c
commit
558f42e304
5 changed files with 146 additions and 10 deletions
|
|
@ -2436,9 +2436,22 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase):
|
|||
database_socket_timeout: float | None = Field(
|
||||
None,
|
||||
description=(
|
||||
"Prisma `socket_timeout` URL param (seconds). When set, an idle/slow "
|
||||
"connection that has not produced data within this window is closed. "
|
||||
"This is the main knob for capping idle DB connections from LiteLLM."
|
||||
"Prisma `socket_timeout` URL param (seconds). When set, an in-flight "
|
||||
"operation that has not produced data within this window is aborted. "
|
||||
"For capping how long idle pooled connections are kept, see "
|
||||
"`database_max_idle_connection_lifetime`."
|
||||
),
|
||||
)
|
||||
database_max_idle_connection_lifetime: float | None = Field(
|
||||
60,
|
||||
description=(
|
||||
"Prisma `max_idle_connection_lifetime` URL param (seconds). A pooled "
|
||||
"connection idle longer than this is closed and replaced instead of "
|
||||
"being handed to the next request. Defaults to 60 so connections are "
|
||||
"recycled before common infra idle timeouts (AWS NLB / RDS Proxy "
|
||||
"~350s, many LBs 60-350s) silently drop them and requests fail with "
|
||||
"`Error { kind: Closed }`. A value pinned on the DATABASE_URL or set "
|
||||
"via `database_extra_connection_params` takes precedence."
|
||||
),
|
||||
)
|
||||
database_extra_connection_params: dict[str, Any] | None = Field(
|
||||
|
|
|
|||
|
|
@ -82,10 +82,30 @@ CONNECTION_PARAM_KEYS: Final[frozenset[str]] = frozenset(
|
|||
"pool_timeout",
|
||||
"connect_timeout",
|
||||
"socket_timeout",
|
||||
"max_idle_connection_lifetime",
|
||||
"pgbouncer",
|
||||
}
|
||||
)
|
||||
|
||||
# Quaint never tests pooled connections on checkout and keeps them idle for
|
||||
# 300s by default, past many infra idle timeouts, so dead sockets surface as
|
||||
# `Error { kind: Closed }`. 60s recycles them first; explicit values win.
|
||||
DEFAULT_MAX_IDLE_CONNECTION_LIFETIME: Final = 60
|
||||
IDLE_LIFETIME_DEFAULT_PARAMS: Final[Mapping[str, int]] = MappingProxyType(
|
||||
{"max_idle_connection_lifetime": DEFAULT_MAX_IDLE_CONNECTION_LIFETIME}
|
||||
)
|
||||
|
||||
|
||||
def idle_lifetime_params(configured: float | None) -> Mapping[str, str | int | float]:
|
||||
"""The `max_idle_connection_lifetime` to add to URLs that do not pin one.
|
||||
|
||||
Applied via ``add_missing_query_params`` so a URL-pinned value always wins,
|
||||
whether the operator configured `database_max_idle_connection_lifetime` or not.
|
||||
"""
|
||||
if configured is None:
|
||||
return IDLE_LIFETIME_DEFAULT_PARAMS
|
||||
return MappingProxyType({"max_idle_connection_lifetime": configured})
|
||||
|
||||
|
||||
def add_missing_query_params(url: str, params: Mapping[str, str | int | float]) -> str:
|
||||
"""Return ``url`` with the ``params`` it does not already carry appended.
|
||||
|
|
|
|||
|
|
@ -1225,6 +1225,7 @@ def run_server(
|
|||
if os.getenv("DATABASE_URL", None) is not None or os.getenv("DIRECT_URL", None) is not None:
|
||||
from litellm.proxy.db.db_url_settings import (
|
||||
add_missing_query_params,
|
||||
idle_lifetime_params,
|
||||
reader_shareable_params,
|
||||
unsupported_db_scheme,
|
||||
unsupported_db_scheme_message,
|
||||
|
|
@ -1253,6 +1254,9 @@ def run_server(
|
|||
disable_prepared_statements=db_disable_prepared_statements,
|
||||
extra_params=db_extra_connection_params,
|
||||
)
|
||||
lifetime_params: Final = idle_lifetime_params(
|
||||
general_settings.get("database_max_idle_connection_lifetime")
|
||||
)
|
||||
if os.getenv("DATABASE_URL", None) is not None:
|
||||
database_url = get_secret("DATABASE_URL", default_value=None)
|
||||
resolved_url: Final[str | None] = str(database_url) if database_url else None
|
||||
|
|
@ -1270,11 +1274,11 @@ def run_server(
|
|||
writer_url,
|
||||
connection_url_params,
|
||||
)
|
||||
os.environ["DATABASE_URL"] = modified_url
|
||||
os.environ["DATABASE_URL"] = add_missing_query_params(modified_url, lifetime_params)
|
||||
if os.getenv("DIRECT_URL", None) is not None:
|
||||
database_url = os.getenv("DIRECT_URL")
|
||||
modified_url = append_query_params(database_url, connection_url_params)
|
||||
os.environ["DIRECT_URL"] = modified_url
|
||||
os.environ["DIRECT_URL"] = add_missing_query_params(modified_url, lifetime_params)
|
||||
# The reader pool is a real pool against the same configured cap, so it
|
||||
# gets the allowlisted pool params. Schema-affecting ones, including any
|
||||
# the operator smuggled in through database_extra_connection_params, stay
|
||||
|
|
@ -1288,10 +1292,13 @@ def run_server(
|
|||
db_lock_timeout,
|
||||
)
|
||||
os.environ["DATABASE_URL_READ_REPLICA"] = add_missing_query_params(
|
||||
_with_query_value(read_replica_url, "options", reader_options)
|
||||
if reader_options
|
||||
else read_replica_url,
|
||||
reader_shareable_params(connection_url_params),
|
||||
add_missing_query_params(
|
||||
_with_query_value(read_replica_url, "options", reader_options)
|
||||
if reader_options
|
||||
else read_replica_url,
|
||||
reader_shareable_params(connection_url_params),
|
||||
),
|
||||
lifetime_params,
|
||||
)
|
||||
subprocess.run(["prisma"], capture_output=True)
|
||||
is_prisma_runnable = True
|
||||
|
|
|
|||
|
|
@ -2452,6 +2452,96 @@ class TestReadReplicaConnectionParams:
|
|||
assert "DATABASE_URL_READ_REPLICA" not in captured
|
||||
|
||||
|
||||
class TestMaxIdleConnectionLifetimeDefault:
|
||||
"""The proxy defaults `max_idle_connection_lifetime` below common infra idle
|
||||
timeouts so stale pooled connections are recycled instead of failing requests."""
|
||||
|
||||
def _config(self, tmp_path, general_settings):
|
||||
config_path = tmp_path / "config.yaml"
|
||||
config_path.write_text(yaml.dump({"model_list": [], "general_settings": general_settings}))
|
||||
return str(config_path)
|
||||
|
||||
def test_default_applied_to_database_and_direct_url(self, tmp_path):
|
||||
captured = _run_server_and_capture_urls(
|
||||
self._config(tmp_path, {}),
|
||||
direct_url="postgresql://t:t@localhost:5432/t",
|
||||
)
|
||||
|
||||
for env_var in ("DATABASE_URL", "DIRECT_URL"):
|
||||
query = urlparse.parse_qs(urlparse.urlparse(captured[env_var]).query)
|
||||
assert query["max_idle_connection_lifetime"] == ["60"], env_var
|
||||
|
||||
def test_url_pinned_value_wins_over_default(self, tmp_path):
|
||||
captured = _run_server_and_capture_urls(
|
||||
self._config(tmp_path, {}),
|
||||
database_url="postgresql://t:t@localhost:5432/t?max_idle_connection_lifetime=300",
|
||||
)
|
||||
|
||||
query = urlparse.parse_qs(urlparse.urlparse(captured["DATABASE_URL"]).query)
|
||||
assert query["max_idle_connection_lifetime"] == ["300"]
|
||||
|
||||
def test_url_pinned_value_wins_over_config_key(self, tmp_path):
|
||||
captured = _run_server_and_capture_urls(
|
||||
self._config(tmp_path, {"database_max_idle_connection_lifetime": 45}),
|
||||
database_url="postgresql://t:t@localhost:5432/t?max_idle_connection_lifetime=300",
|
||||
)
|
||||
|
||||
query = urlparse.parse_qs(urlparse.urlparse(captured["DATABASE_URL"]).query)
|
||||
assert query["max_idle_connection_lifetime"] == ["300"]
|
||||
|
||||
def test_config_key_overrides_default(self, tmp_path):
|
||||
captured = _run_server_and_capture_urls(
|
||||
self._config(tmp_path, {"database_max_idle_connection_lifetime": 45}),
|
||||
)
|
||||
|
||||
query = urlparse.parse_qs(urlparse.urlparse(captured["DATABASE_URL"]).query)
|
||||
assert query["max_idle_connection_lifetime"] == ["45"]
|
||||
|
||||
def test_extra_connection_params_override_default(self, tmp_path):
|
||||
captured = _run_server_and_capture_urls(
|
||||
self._config(
|
||||
tmp_path,
|
||||
{"database_extra_connection_params": {"max_idle_connection_lifetime": 120}},
|
||||
),
|
||||
)
|
||||
|
||||
query = urlparse.parse_qs(urlparse.urlparse(captured["DATABASE_URL"]).query)
|
||||
assert query["max_idle_connection_lifetime"] == ["120"]
|
||||
|
||||
def test_read_replica_gets_the_default(self, tmp_path):
|
||||
captured = _run_server_and_capture_urls(
|
||||
self._config(tmp_path, {}),
|
||||
read_replica_url="postgresql://t:t@reader:5432/t",
|
||||
)
|
||||
|
||||
query = urlparse.parse_qs(urlparse.urlparse(captured["DATABASE_URL_READ_REPLICA"]).query)
|
||||
assert query["max_idle_connection_lifetime"] == ["60"]
|
||||
|
||||
def test_replica_pinned_value_wins(self, tmp_path):
|
||||
captured = _run_server_and_capture_urls(
|
||||
self._config(tmp_path, {"database_max_idle_connection_lifetime": 45}),
|
||||
read_replica_url="postgresql://t:t@reader:5432/t?max_idle_connection_lifetime=200",
|
||||
)
|
||||
|
||||
query = urlparse.parse_qs(urlparse.urlparse(captured["DATABASE_URL_READ_REPLICA"]).query)
|
||||
assert query["max_idle_connection_lifetime"] == ["200"]
|
||||
|
||||
def test_config_key_reaches_the_read_replica(self, tmp_path):
|
||||
captured = _run_server_and_capture_urls(
|
||||
self._config(tmp_path, {"database_max_idle_connection_lifetime": 45}),
|
||||
read_replica_url="postgresql://t:t@reader:5432/t",
|
||||
)
|
||||
|
||||
query = urlparse.parse_qs(urlparse.urlparse(captured["DATABASE_URL_READ_REPLICA"]).query)
|
||||
assert query["max_idle_connection_lifetime"] == ["45"]
|
||||
|
||||
def test_idle_lifetime_params_prefers_configured_value(self):
|
||||
from litellm.proxy.db.db_url_settings import idle_lifetime_params
|
||||
|
||||
assert dict(idle_lifetime_params(45)) == {"max_idle_connection_lifetime": 45}
|
||||
assert dict(idle_lifetime_params(None)) == {"max_idle_connection_lifetime": 60}
|
||||
|
||||
|
||||
class TestTokenAuthCliFlags:
|
||||
"""`--azure_postgresql_auth` has to reach the URL assembly the same way the env var does."""
|
||||
|
||||
|
|
|
|||
8
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
8
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -25435,9 +25435,15 @@ export interface components {
|
|||
database_extra_connection_params?: {
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
/**
|
||||
* Database Max Idle Connection Lifetime
|
||||
* @description Prisma `max_idle_connection_lifetime` URL param (seconds). A pooled connection idle longer than this is closed and replaced instead of being handed to the next request. Defaults to 60 so connections are recycled before common infra idle timeouts (AWS NLB / RDS Proxy ~350s, many LBs 60-350s) silently drop them and requests fail with `Error { kind: Closed }`. A value pinned on the DATABASE_URL or set via `database_extra_connection_params` takes precedence.
|
||||
* @default 60
|
||||
*/
|
||||
database_max_idle_connection_lifetime: number | null;
|
||||
/**
|
||||
* Database Socket Timeout
|
||||
* @description Prisma `socket_timeout` URL param (seconds). When set, an idle/slow connection that has not produced data within this window is closed. This is the main knob for capping idle DB connections from LiteLLM.
|
||||
* @description Prisma `socket_timeout` URL param (seconds). When set, an in-flight operation that has not produced data within this window is aborted. For capping how long idle pooled connections are kept, see `database_max_idle_connection_lifetime`.
|
||||
*/
|
||||
database_socket_timeout?: number | null;
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue