Skip to content

fix(customer-session): return 409 on ambiguous external customer ID across orgs - #13913

Open
detail-app[bot] wants to merge 2 commits into
mainfrom
detail/bug-fix/fix-customer-session-return-409-on-ambiguous-exter-0253b0
Open

fix(customer-session): return 409 on ambiguous external customer ID across orgs#13913
detail-app[bot] wants to merge 2 commits into
mainfrom
detail/bug-fix/fix-customer-session-return-409-on-ambiguous-exter-0253b0

Conversation

@detail-app

@detail-app detail-app Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Detail bug report: View on Detail

Summary

POST /v1/customer-sessions/ returned HTTP 500 (triggering Sentry alerts) when a User token spans multiple organizations that each have a customer with the same external_id. It now returns HTTP 409 with a disambiguation message, matching the existing MemberService behavior.

  • Issue: polarsource/feedback#395

Bug

CustomerSessionService._get_customer() calls CustomerRepository.get_readable_by_external_id(), which uses scalar_one_or_none(). The schema allows the same external_id across organizations (UniqueConstraint("organization_id", "external_id")), and the endpoint accepts User tokens that can span multiple orgs. When two accessible orgs each have a customer with the same external_id, SQLAlchemy raises MultipleResultsFound — which propagated uncaught as HTTP 500, violating the AGENTS.md convention that expected business conflicts return a proper status code (not 500/Sentry).

This was inconsistent with three other callers of the same repository method (MemberService.create, MemberService.get_by_external_id, backoffice/orders_import.py), all of which catch the exception and return a 409.

Introduced in commit 6115ebe7 (PR #11146), which migrated customer_session to use get_readable_by_external_id() without adding the exception handler.

Fix

  • Moved AmbiguousExternalCustomerID (a PolarError with status_code=409) from polar/member/service.py to a new shared module polar/customer/exceptions.py, since the error is about customers and is now consumed by both services.
  • Added try/except MultipleResultsFound around the get_readable_by_external_id() call in CustomerSessionService._get_customer(), raising AmbiguousExternalCustomerID — mirroring the exact pattern already used in MemberService.
  • Updated MemberService and MemberEndpoints to import from the shared location (no behavior change).
  • Declared 409: AmbiguousExternalCustomer in the endpoint's responses= so the OpenAPI schema and generated SDK expose the conflict shape.
  • Regenerated clients/packages/client/src/v1.ts.

Testing

  • Added test_external_customer_id_ambiguous_across_organizations to tests/customer_session/test_endpoints.py — creates two customers with the same external_id across two orgs the user belongs to, POSTs to /v1/customer-sessions/, and asserts 409.
  • All existing customer_session endpoint and service tests pass (33 tests) — confirms no regression in the happy path, org-scoped tokens, customer_id lookups, member-model-enabled flows, and not-found cases.
  • All member tests pass (140 tests), including the existing test_external_ambiguous_across_organizations tests — confirms the moved exception class still produces 409 in the member module.
  • All customer tests pass (178 tests) — confirms no import cycles from the new exceptions.py.
  • checkout service tests pass (359 tests) — confirms no import cycle via polar.member.service.
  • ruff, mypy, and all custom AST linters (org-scope, subquery, frontend-url) pass.
  • OpenAPI client regenerated and typechecks cleanly.

I attempted end-to-end verification against a standalone uvicorn server with a real Personal Access Token, but the server's auth middleware could not resolve the PAT outside the pytest test client (returned RuntimeError: AuthSubject is not present in the request state). This is an auth-infrastructure issue unrelated to the fix — the integration tests exercise the identical code path through the full ASGI stack (real PolarError handler, endpoint, service, and DB).


Automatic Fixes PRs can be configured here.

Review in cubic

@detail-app
detail-app Bot requested a review from frankie567 as a code owner August 20, 2026 12:15
@detail-app
detail-app Bot requested a review from joebon August 20, 2026 12:15
@vercel

vercel Bot commented Aug 20, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
orbit Ready Ready Preview Aug 20, 2026 12:50pm
polar-test Ready Ready Preview Aug 20, 2026 12:50pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

OpenAPI Changes

Operations

  • 🔀 POST /v1/customer-sessions/ (modified)
    • 🔼 Response 409 added
@polar-cycle-pr-review
polar-cycle-pr-review Bot requested a review from joebon August 20, 2026 12:21
@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Preview Environment
URL: https://pr-13913.taildbff7b.ts.net
API: https://pr-13913.taildbff7b.ts.net/v1/
Logs: backend · frontend
SHA: fbbd153d00655c6ba15a1d5327f0dcc0fe681874

@joebon
joebon added this pull request to the merge queue Aug 20, 2026
@frankie567
frankie567 removed this pull request from the merge queue due to a manual request Aug 20, 2026
Comment thread server/polar/customer/exceptions.py Outdated
from polar.exceptions import PolarError


class AmbiguousExternalCustomerID(PolarError):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@detail-dev Why putting the exception in a dedicated module? This is not our pattern. It should Iive server/polar/customer_session/service.py

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in fbbd153. Removed and defined locally in each module's ( and ).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

2 participants