Skip to content

GH-4516: map an unknown tenant id to a 404 ProblemDetails instead of a 500 - #4551

Merged
jeremydmiller merged 1 commit into
mainfrom
gh-4516-tenancy-problem-details
Sep 23, 2026
Merged

jeremydmiller merged 1 commit into
mainfrom
gh-4516-tenancy-problem-details

Conversation

@jeremydmiller

Copy link
Copy Markdown
Member

Closes #4516. Builds on the API from #4512.

The gap

There are two different tenancy failures on an HTTP request, and only one was handled:

Failure Meaning Before
Missing tenant id "you did not say which tenant" 400 ProblemDetails, already handled by [RequiresTenant] / TenantId.AssertExists()
Unknown tenant id "the tenant you named does not exist" unhandled 500

An unknown tenant id throws JasperFx.MultiTenancy.UnknownTenantIdException from the store or from Wolverine's own tenant sources, which Wolverine.Http never caught — so the client got a 500 for what is a client-side error.

opts.MapUnknownTenantToNotFound();

maps it to a 404 ProblemDetails titled Unknown tenant and stamps ProducesProblem(404) so OpenAPI advertises it. Same shape as #4512's conflict mapping — middleware plus an IHttpPolicy, one call, same optional chain predicate.

404, and the distinction it protects

404 rather than 400, as the issue proposes: it reads as "the thing you addressed does not exist", and it keeps 400 meaning "you did not say which tenant". Collapsing both onto one status loses the distinction a caller needs to tell a routing bug from a provisioning one — so there's a test asserting the missing-id case still answers 400 with the mapping turned on.

Defaults to the tenanted chains ([RequiresTenant] / [MaybeTenanted]), since a chain that resolves no tenant cannot fail to resolve one. A test asserts the 404 is not advertised on a [NotTenanted] chain.

DisabledTenantException is deliberately absent

The issue anticipated this. A disabled tenant deserves a 403 — the tenant exists and access is refused — but it reads as unknown on Marten and Polecat today and JasperFx has no lifted DisabledTenantException to catch. That's recorded in the XML docs on MapUnknownTenantToNotFound so it can be added in one place when it lands.

Testing

Five new tests, including a negative control: without the opt-in, UnknownTenantIdException still escapes. That's what makes the other four mean something — they prove the opt-in is what changes the answer, rather than something else in the pipeline.

Wolverine.Http.Tests  1067 passed, 10 skipped, 0 failed (full suite)

The #4547 dedupe flake happened not to fire on this run.

Docs: a new Unknown Tenants as 404 section in exception-handling.md, including the message-handler equivalent (OnException<UnknownTenantIdException>().MoveToErrorQueue() — never retry, it's deterministic).

🤖 Generated with Claude Code

https://claude.ai/code/session_01VDUrBeB4tTnKj4AExCS1nj

…a 500

There are two different tenancy failures on an HTTP request, and only one of them was
handled. A MISSING mandatory tenant id already stops the request with a 400
ProblemDetails through [RequiresTenant] / TenantId.AssertExists(). An UNKNOWN tenant id --
present on the request, but with no database or registration behind it -- throws
JasperFx.MultiTenancy.UnknownTenantIdException from the store or from Wolverine's own
tenant sources, which Wolverine.Http did not catch. The client got a 500 for what is a
client side error.

    opts.MapUnknownTenantToNotFound();

maps it to a 404 ProblemDetails titled "Unknown tenant" and stamps ProducesProblem(404) so
the OpenAPI document advertises it. Built on the same shape as #4512's conflict mapping:
middleware plus an IHttpPolicy, one call, and the same optional chain predicate.

404 rather than 400 on purpose, as the issue proposes: it reads as "the thing you
addressed does not exist", and it keeps 400 meaning "you did not say which tenant".
Collapsing both onto one status loses the distinction a caller needs to tell a routing bug
from a provisioning one -- and there is a test asserting the missing-id case still answers
400.

Defaults to the tenanted chains -- those declared [RequiresTenant] or [MaybeTenanted] --
since a chain that resolves no tenant cannot fail to resolve one. A test asserts the 404
is NOT advertised on a [NotTenanted] chain.

DisabledTenantException is deliberately absent. The issue anticipated this: a disabled
tenant deserves a 403 because the tenant exists and access is refused, but it reads as
unknown on Marten and Polecat today and JasperFx has no lifted DisabledTenantException to
catch. Recorded in the XML docs on MapUnknownTenantToNotFound so it can be added in one
place when it lands.

Five new tests, including a negative control -- without the opt in, the exception still
escapes -- so the test proves the opt in is what changes the answer rather than something
else in the pipeline.

Full Wolverine.Http.Tests suite: 1067 passed, 10 skipped, 0 failed.

Closes #4516

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VDUrBeB4tTnKj4AExCS1nj
@jeremydmiller
jeremydmiller merged commit 8b03fd3 into main Sep 23, 2026
41 of 43 checks passed
jeremydmiller added a commit that referenced this pull request Sep 23, 2026
`unknown_tenant_problem_details_4516.without_the_opt_in_the_unknown_tenant_still_escapes`
went red on CI the moment #4551 merged, and it takes the whole `test` job with it -- so
every open PR is red regardless of its contents (#4553, #4557, #4558 at the time of
writing, two of them from contributors).

The test was self contradictory. It asked Alba to assert a 404 inside a scenario it
expected to THROW:

    await Should.ThrowAsync<UnknownTenantIdException>(async () =>
        await host.Scenario(x =>
        {
            x.Get.Url("/gh4516/tenanted?tenantId=ghost");
            x.StatusCodeShouldBe(404);      // <- asserted on a request expected to blow up
        }));

Locally the UnknownTenantIdException propagated before Alba evaluated its assertions, so
the expected exception won and the test passed. On CI the host turned the exception into
a 500 first, so Alba's own 404 assertion fired and raised ScenarioAssertionException
instead of UnknownTenantIdException -- a different type, so Should.ThrowAsync failed.

The control's actual purpose is narrower than what it asserted: it exists to show that
MapUnknownTenantToNotFound() is what produces the 404, not something else in the pipeline.
It is not a claim about HOW an unmapped failure surfaces, and that is exactly the part
that varies by environment. It now uses IgnoreStatusCode() and asserts only that the
response is not a mapped 404, tolerating the other surfacing -- the exception escaping the
scenario -- and nothing else.


Claude-Session: https://claude.ai/code/session_01VDUrBeB4tTnKj4AExCS1nj

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Wolverine.HTTP: map UnknownTenantIdException (and DisabledTenantException once lifted) to 404/403 ProblemDetails alongside the #4512 concurrency mapping

1 participant