Problem
request.state.tenant_source is documented as None when no tenant was bound, and the built-in paths honour that. The (tenant_id, source) tuple path in _normalise and the tenants module's resolver both blank the source when tenant_id is None. A custom resolver that returns a TenantResolution directly skips that: _normalise passes the object through unchanged.
if isinstance(result, TenantResolution):
return result # TenantResolution(None, "header") stays source="header"
So a resolver returning TenantResolution(None, "header", ("X-Tenant-ID",)) leaves the request with tenant_id=None, tenant_source="header". Code that branches on tenant_source (for example to choose between Vary: <header> and Cache-Control: private, the use case in #367) then believes a tenant was resolved from the header when none was.
Reproduction
Found by independent QA on #384 and still on main at 7db61a0 (framework/hosting/simple_module_hosting/_tenant.py, _normalise): register app.state.tenant_resolver returning TenantResolution(None, "header", ("X-Tenant-ID",)) and read request.state in a route. It shows tenant_id=None, tenant_source='header'.
Expected
_normalise applies the same rule to every result shape: when tenant_id is None, the source is None. The vary names are kept, since the header's absence was still an input. For example:
if isinstance(result, TenantResolution):
return result if result.tenant_id is not None else TenantResolution(None, None, result.vary)
Tests to add
- A resolver returning
TenantResolution(None, "header", ("X-Tenant-ID",)) → tenant_source is None, and the response still has Vary: X-Tenant-ID.
Problem
request.state.tenant_sourceis documented asNonewhen no tenant was bound, and the built-in paths honour that. The(tenant_id, source)tuple path in_normaliseand the tenants module's resolver both blank the source whentenant_id is None. A custom resolver that returns aTenantResolutiondirectly skips that:_normalisepasses the object through unchanged.So a resolver returning
TenantResolution(None, "header", ("X-Tenant-ID",))leaves the request withtenant_id=None, tenant_source="header". Code that branches ontenant_source(for example to choose betweenVary: <header>andCache-Control: private, the use case in #367) then believes a tenant was resolved from the header when none was.Reproduction
Found by independent QA on #384 and still on
mainat 7db61a0 (framework/hosting/simple_module_hosting/_tenant.py,_normalise): registerapp.state.tenant_resolverreturningTenantResolution(None, "header", ("X-Tenant-ID",))and readrequest.statein a route. It showstenant_id=None, tenant_source='header'.Expected
_normaliseapplies the same rule to every result shape: whentenant_id is None, the source isNone. Thevarynames are kept, since the header's absence was still an input. For example:Tests to add
TenantResolution(None, "header", ("X-Tenant-ID",))→tenant_source is None, and the response still hasVary: X-Tenant-ID.