aio-libs/aiohttp · error · ValueError
base_url must have a trailing '/'
Error message
base_url must have a trailing '/'
What it means
Raised by ClientSession.__init__ when base_url's path does not end with '/'. aiohttp resolves relative request URLs against base_url, so a missing trailing slash would silently drop the last path segment (e.g. 'http://x/api' + 'foo' resolves to 'http://x/foo', not 'http://x/api/foo'). The check is intentional to prevent wrong request URLs.
Solutions
- Add a trailing '/' to base_url: ClientSession(base_url='http://example.com/api/').
- Construct with an aiohttp.URL: ClientSession(base_url=URL('http://example.com/api/')).
- If the value comes from config, normalize at load time: base_url = cfg.rstrip('/') + '/'.
Example fix
// before session = ClientSession(base_url='http://example.com/api') // after session = ClientSession(base_url='http://example.com/api/')
Defensive patterns
Strategy: validation
Validate before calling
from aiohttp import URL
def normalize_base_url(raw: str) -> URL:
u = URL(raw)
if not u.path.endswith('/'):
u = u.with_path(u.path + '/')
return u
# at session creation
ClientSession(base_url=normalize_base_url(cfg['base_url'])) Type guard
from aiohttp import URL
def has_trailing_slash(u: URL) -> bool:
return u.path.endswith('/') Try / catch
try:
session = ClientSession(base_url=raw)
except ValueError as e:
if 'trailing' in str(e):
raw = raw.rstrip('/') + '/'
session = ClientSession(base_url=raw)
else:
raise Prevention
- Centralize base_url construction in one helper that always appends '/'.
- Read base_url from config typed as a yarl.URL, not a raw string.
- Add a unit test asserting base_url.path.endswith('/').
When it happens
Trigger: Calling ClientSession(base_url='http://example.com/api') (no trailing '/'). Passing base_url as a string without a trailing slash; the URL is parsed and self._base_url.path.endswith('/') fails.
Common situations: Copying an API base URL from a browser bar or docs that omits the trailing slash. Migrating from requests/httpx where trailing slashes are not enforced. Reading base_url from an env var that was trimmed.
Related errors
- compress must be one of True, False, 'deflate', or 'gzip'
- Domain cannot be empty
- InvalidURL
- None is not allowed as login value
- None is not allowed as password value
AI-assisted analysis of aio-libs/aiohttp@d041d4d0fd (2026-08-11).
Data as JSON: /api/errors/15fb1e350e1b4caf.
Report an issue: GitHub.
Appendix: source
Thrown at aiohttp/client.py:337
max_line_size: int = 8190,
max_field_size: int = 8190,
max_headers: int = 128,
fallback_charset_resolver: _CharsetResolver = lambda r, b: "utf-8",
middlewares: Sequence[ClientMiddlewareType] = (),
ssl_shutdown_timeout: _SENTINEL | None | float = sentinel,
) -> None:
# We initialise _connector to None immediately, as it's referenced in __del__()
# and could cause issues if an exception occurs during initialisation.
self._connector: BaseConnector | None = None
if base_url is None or isinstance(base_url, URL):
self._base_url: URL | None = base_url
self._base_url_origin = None if base_url is None else base_url.origin()
else:
self._base_url = URL(base_url)
self._base_url_origin = self._base_url.origin()
assert self._base_url.absolute, "Only absolute URLs are supported"
if self._base_url is not None and not self._base_url.path.endswith("/"):
raise ValueError("base_url must have a trailing '/'")
if not isinstance(ssl, SSL_ALLOWED_TYPES):
raise TypeError(
"ssl should be SSLContext, Fingerprint, or bool, "
f"got {ssl!r} instead."
)
loop = asyncio.get_running_loop()
if timeout is sentinel or timeout is None:
timeout = ClientTimeout()
if not isinstance(timeout, ClientTimeout):
raise ValueError(
f"timeout parameter cannot be of {type(timeout)} type, "
"please use 'timeout=ClientTimeout(...)'",
)
self._timeout = timeout
View on GitHub (pinned to d041d4d0fd)