aio-libs/aiohttp · error · ValueError

Scheme not supported

Error message

Scheme not supported

What it means

Thrown by Domain.validation() when the domain string passed to Application.add_domain() (or a Domain/MatchDomain rule) contains '://'. aiohttp attaches sub-applications by host, not by full URL, so it prepends 'http://' internally to parse the host; an embedded scheme breaks that logic and is rejected. Pass a bare hostname like 'example.com' or 'example.com:8080' instead of 'http://example.com'.

Solutions

  1. Strip the scheme before passing the host: use URL(value).raw_host (and URL(value).port) instead of the raw string.
  2. Pass only the host[:port] form, e.g. 'example.com' or 'example.com:8443'.
  3. Sanitize env-derived config: host = value.split('://')[-1] before add_domain.

Example fix

// before
app.add_domain('https://api.example.com', sub_app)
// after
from yarl import URL
u = URL('https://api.example.com')
app.add_domain(f'{u.raw_host}:{u.port}' if u.port else u.raw_host, sub_app)
Defensive patterns

Strategy: validation

Validate before calling

from yarl import URL

def normalize_domain(value: str) -> str:
    if '://' in value:
        u = URL(value)
        host = u.raw_host
        return f'{host}:{u.port}' if u.port and u.port != 80 else host
    return value

# usage:
app.add_domain(normalize_domain(cfg_host), sub_app)

Type guard

import re
DNS_LABEL = re.compile(r'(?!-)[a-z0-9*-]{1,63}(?<!-)', re.I)

def is_bare_host(value: str) -> bool:
    return '://' not in value and all(
        DNS_LABEL.fullmatch(p) for p in value.rstrip('.').lower().split('.') if p
    )

Try / catch

try:
    app.add_domain(host_str, sub_app)
except ValueError as e:
    if 'Scheme not supported' in str(e):
        log.error('add_domain needs a bare host, got %r', host_str)
    raise

Prevention

When it happens

Trigger: Calling app.add_domain('https://api.example.com', sub_app) or constructing Domain('http://localhost:8080'). Any value containing '://' after rstrip('.').lower() hits line 786.

Common situations: Copy-pasting a full URL from a browser or config file into add_domain(); migrating from a framework that accepts full URLs; reading the host from an environment variable that includes the scheme (e.g. BASE_URL=https://api.example.com).

Related errors


AI-assisted analysis of aio-libs/aiohttp@d041d4d0fd (2026-08-11). Data as JSON: /api/errors/f21cd9bf2b1f56c0. Report an issue: GitHub.

Appendix: source

Thrown at aiohttp/web_urldispatcher.py:786

class Domain(AbstractRuleMatching):
    re_part = re.compile(r"(?!-)[a-z\d-]{1,63}(?<!-)")

    def __init__(self, domain: str) -> None:
        super().__init__()
        self._domain = self.validation(domain)

    @property
    def canonical(self) -> str:
        return self._domain

    def validation(self, domain: str) -> str:
        if not isinstance(domain, str):
            raise TypeError("Domain must be str")
        domain = domain.rstrip(".").lower()
        if not domain:
            raise ValueError("Domain cannot be empty")
        elif "://" in domain:
            raise ValueError("Scheme not supported")
        url = URL("http://" + domain)
        assert url.raw_host is not None
        if not all(self.re_part.fullmatch(x) for x in url.raw_host.split(".")):
            raise ValueError("Domain not valid")
        if url.port == 80:
            return url.raw_host
        return f"{url.raw_host}:{url.port}"

    async def match(self, request: Request) -> bool:
        host = request.headers.get(hdrs.HOST)
        if not host:
            return False
        return self.match_domain(host)

    def match_domain(self, host: str) -> bool:
        return host.lower() == self._domain

    def get_info(self) -> _InfoDict:

View on GitHub (pinned to d041d4d0fd)