Skip to content

Commit 59773c7

Browse files
committed
feat(config): accept provider connection strings verbatim and translate libpq SSL params
1 parent a6167e5 commit 59773c7

5 files changed

Lines changed: 169 additions & 7 deletions

File tree

.env.example

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,11 @@ DATABASE_URL=postgresql+asyncpg://documind:documind@localhost:5432/documind
99
# Host port for the compose Postgres. Change if you already run Postgres on 5432,
1010
# and update DATABASE_URL to match.
1111
POSTGRES_PORT=5432
12-
# Neon: use the POOLED connection string on serverless and add ?sslmode=require
13-
# DATABASE_URL=postgresql+asyncpg://user:pass@ep-xxx-pooler.region.aws.neon.tech/documind
12+
# Neon / Supabase / Render: paste the connection string exactly as the console
13+
# gives it to you. The driver is corrected and libpq-only parameters
14+
# (sslmode, channel_binding) are translated automatically — see
15+
# Settings._normalise_database_url. Use the POOLED host on serverless.
16+
# DATABASE_URL=postgresql://user:pass@ep-xxx-pooler.region.aws.neon.tech/db?sslmode=require
1417

1518
# --- OpenAI -----------------------------------------------------------------
1619
OPENAI_API_KEY=sk-...

app/config.py

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from functools import lru_cache
1313
from typing import Literal
1414

15-
from pydantic import BaseModel
15+
from pydantic import BaseModel, model_validator
1616
from pydantic_settings import BaseSettings, SettingsConfigDict
1717

1818

@@ -102,6 +102,9 @@ class Settings(BaseSettings):
102102
# Neon: use the *pooled* connection string on serverless.
103103
# Must be the asyncpg driver: postgresql+asyncpg://...
104104
database_url: str = "postgresql+asyncpg://documind:documind@localhost:5432/documind"
105+
# Set from the URL's libpq params by the validator below; asyncpg takes SSL
106+
# as a connect argument, not a query string.
107+
database_ssl: str | None = None
105108

106109
# --- OpenAI -------------------------------------------------------------
107110
openai_api_key: str = ""
@@ -162,6 +165,80 @@ class Settings(BaseSettings):
162165
cors_origins: str = "*"
163166
api_key: str | None = None # optional shared-secret gate for write endpoints
164167

168+
@model_validator(mode="after")
169+
def _normalise_database_url(self) -> Settings:
170+
"""Accept a connection string copied verbatim from a provider console.
171+
172+
Neon, Supabase and Render all hand you a libpq URL:
173+
174+
postgresql://user:pw@host/db?sslmode=require&channel_binding=require
175+
176+
Two things make that unusable here. The scheme selects psycopg, not
177+
asyncpg. And `sslmode`/`channel_binding` are libpq parameters — asyncpg
178+
does not accept them and raises `TypeError: connect() got an unexpected
179+
keyword argument 'sslmode'`, which reads like a code bug rather than a
180+
config one.
181+
182+
Rather than document a hand-edit that everyone will get wrong once, the
183+
URL is normalised here: driver corrected, libpq-only parameters lifted
184+
out, and SSL carried across as an asyncpg connect argument.
185+
"""
186+
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
187+
188+
url = self.database_url.strip()
189+
if not url:
190+
return self
191+
192+
parts = urlsplit(url)
193+
194+
scheme = parts.scheme
195+
if scheme in ("postgres", "postgresql"):
196+
scheme = "postgresql+asyncpg"
197+
elif scheme.startswith("postgresql+") and scheme != "postgresql+asyncpg":
198+
# A sync driver here would fail much later, inside the engine.
199+
raise ValueError(
200+
f"DATABASE_URL must use the asyncpg driver, got {parts.scheme!r}. "
201+
"Use postgresql+asyncpg://…"
202+
)
203+
204+
# libpq spellings asyncpg cannot take as query parameters.
205+
ssl_mode = self.database_ssl
206+
kept: list[tuple[str, str]] = []
207+
for key, value in parse_qsl(parts.query, keep_blank_values=True):
208+
lowered = key.lower()
209+
if lowered in ("sslmode", "ssl"):
210+
ssl_mode = value or ssl_mode
211+
elif lowered == "channel_binding":
212+
# asyncpg negotiates channel binding itself; the parameter has
213+
# no equivalent and is safe to drop.
214+
continue
215+
else:
216+
kept.append((key, value))
217+
218+
object.__setattr__(
219+
self,
220+
"database_url",
221+
urlunsplit((scheme, parts.netloc, parts.path, urlencode(kept), parts.fragment)),
222+
)
223+
# Managed Postgres is TLS-only; default to requiring it for any remote
224+
# host so a stripped `sslmode` cannot silently downgrade the connection.
225+
if ssl_mode is None and parts.hostname not in (None, "localhost", "127.0.0.1", "db"):
226+
ssl_mode = "require"
227+
object.__setattr__(self, "database_ssl", ssl_mode)
228+
return self
229+
230+
def asyncpg_connect_args(self) -> dict:
231+
"""Driver arguments shared by the app engine and Alembic."""
232+
args: dict = {
233+
# Neon's pooler runs in transaction mode, which is incompatible
234+
# with asyncpg's prepared-statement cache.
235+
"statement_cache_size": 0,
236+
"prepared_statement_cache_size": 0,
237+
}
238+
if self.database_ssl and self.database_ssl != "disable":
239+
args["ssl"] = self.database_ssl
240+
return args
241+
165242
@property
166243
def is_serverless(self) -> bool:
167244
return self.environment == "vercel"

app/db/migrations/env.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ async def run_async_migrations() -> None:
4141
config.get_section(config.config_ini_section, {}),
4242
prefix="sqlalchemy.",
4343
poolclass=pool.NullPool,
44-
connect_args={"statement_cache_size": 0, "prepared_statement_cache_size": 0},
44+
connect_args=settings.asyncpg_connect_args(),
4545
)
4646
async with connectable.connect() as connection:
4747
await connection.run_sync(do_run_migrations)

app/db/session.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,7 @@ def _build_engine() -> AsyncEngine:
3030
kwargs: dict = {
3131
"echo": False,
3232
"pool_pre_ping": True,
33-
# Neon's pooler runs in transaction mode, which is incompatible with
34-
# prepared-statement caching in asyncpg.
35-
"connect_args": {"statement_cache_size": 0, "prepared_statement_cache_size": 0},
33+
"connect_args": settings.asyncpg_connect_args(),
3634
}
3735
if settings.is_serverless:
3836
kwargs["poolclass"] = NullPool

tests/test_config.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
"""Database URL normalisation.
2+
3+
Provider consoles hand out libpq URLs. Pasting one verbatim used to fail deep
4+
inside asyncpg with `TypeError: connect() got an unexpected keyword argument
5+
'sslmode'` — an error that reads like a code bug and sends you looking in the
6+
wrong place entirely.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import pytest
12+
13+
from app.config import Settings
14+
15+
16+
def build(url: str) -> Settings:
17+
# _env_file=None keeps a developer's real .env out of the assertions.
18+
return Settings(database_url=url, _env_file=None)
19+
20+
21+
def test_a_neon_url_pasted_verbatim_is_usable():
22+
s = build(
23+
"postgresql://user:pw@ep-x-pooler.ap-southeast-1.aws.neon.tech/db"
24+
"?sslmode=require&channel_binding=require"
25+
)
26+
27+
assert s.database_url == (
28+
"postgresql+asyncpg://user:pw@ep-x-pooler.ap-southeast-1.aws.neon.tech/db"
29+
)
30+
assert s.database_ssl == "require"
31+
assert s.asyncpg_connect_args()["ssl"] == "require"
32+
33+
34+
@pytest.mark.parametrize("scheme", ["postgres", "postgresql"])
35+
def test_the_sync_scheme_is_upgraded_to_asyncpg(scheme):
36+
s = build(f"{scheme}://user:pw@localhost:5432/db")
37+
assert s.database_url.startswith("postgresql+asyncpg://")
38+
39+
40+
def test_an_explicit_sync_driver_is_rejected_loudly():
41+
"""psycopg here would fail much later, inside engine creation, with a far
42+
less obvious message."""
43+
with pytest.raises(ValueError, match="asyncpg"):
44+
build("postgresql+psycopg://user:pw@localhost/db")
45+
46+
47+
def test_an_already_correct_url_is_left_alone():
48+
url = "postgresql+asyncpg://documind:documind@localhost:5432/documind"
49+
assert build(url).database_url == url
50+
51+
52+
def test_channel_binding_is_dropped_but_other_params_survive():
53+
s = build("postgresql://u:p@host/db?channel_binding=require&application_name=documind")
54+
55+
assert "channel_binding" not in s.database_url
56+
assert "application_name=documind" in s.database_url
57+
58+
59+
def test_a_remote_host_defaults_to_requiring_tls():
60+
"""A stripped sslmode must not silently downgrade a managed connection."""
61+
s = build("postgresql://u:p@ep-x.aws.neon.tech/db")
62+
assert s.database_ssl == "require"
63+
assert s.asyncpg_connect_args()["ssl"] == "require"
64+
65+
66+
@pytest.mark.parametrize("host", ["localhost", "127.0.0.1", "db"])
67+
def test_local_hosts_do_not_get_tls_forced(host):
68+
"""docker compose Postgres speaks plaintext; requiring TLS would break it."""
69+
s = build(f"postgresql://u:p@{host}:5432/db")
70+
assert s.database_ssl is None
71+
assert "ssl" not in s.asyncpg_connect_args()
72+
73+
74+
def test_sslmode_disable_is_honoured():
75+
s = build("postgresql://u:p@remote.example.com/db?sslmode=disable")
76+
assert "ssl" not in s.asyncpg_connect_args()
77+
78+
79+
def test_the_prepared_statement_cache_is_always_disabled():
80+
"""Neon's pooler runs in transaction mode; asyncpg's statement cache breaks
81+
against it."""
82+
args = build("postgresql://u:p@localhost/db").asyncpg_connect_args()
83+
assert args["statement_cache_size"] == 0
84+
assert args["prepared_statement_cache_size"] == 0

0 commit comments

Comments
 (0)