Skip to content

Issue #1261: fix: answer 409 when an identity-mapping collision survives the retry - #1312

Open
dereck-symmetry wants to merge 1 commit into
mainfrom
issue-1261-identity-mapper-409-on-conflict
Open

dereck-symmetry wants to merge 1 commit into
mainfrom
issue-1261-identity-mapper-409-on-conflict

Conversation

@dereck-symmetry

@dereck-symmetry dereck-symmetry commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor
Description of Change

Problem. #1260 made a save_mappings batch retry once when a concurrent insert invalidates its pre-read. A collision that survives that retry still raised DataStoreException. That reached default_exception_handler as a 500 with a correlation UUID and "Internal server error. Please try again later.", the same answer the service gives for a real datastore outage. Callers therefore had no correct retry policy: a conflict their own concurrent traffic caused looked like a broken server. This was criterion 4 of #1216, deliberately left out of #1260.

Solution. This follows the shape #1261 suggested:

  1. IdentityMappingConflictException in components/lif/identity_mapper_storage/core.py, next to DeleteOutcome. It's deliberately not in components/lif/exceptions: 12 projects package that brick, and the Identity Mapper workflow's paths: doesn't list it (CF/CI: deploy workflow paths: filters miss packaged bricks — a brick-only change merges green and never rebuilds the image (9 of 11 services affected) #1171).
  2. identity_mapper_storage_sql: the retry's IntegrityError is converted into the conflict exception (raise ... from e). The bound is unchanged: still exactly one retry.
  3. Both save_mapping and save_mappings re-raise it ahead of their broad except Exception: raise DataStoreException from e, next to ValueError. Without this step the new exception is silently swallowed and nothing changes.
  4. identity_mapper_restapi: a handler that returns 409 with "The save collided with a concurrent write of the same mapping. The request may be retried." There's no correlation UUID, and it logs at warning like the neighbouring 404/400 handlers.

A deliberate choice: no constraint-name check. The acceptance criteria say the 409 must not widen to unrelated IntegrityErrors. On this table, an IntegrityError that survives the retry can only be the natural key:

Matching uq_identity_mapping by name would need dialect-specific message parsing: MariaDB names the key, SQLite lists the columns. So the reasoning is recorded in a comment at the conversion point instead. Genuine datastore failures still return 500 with their UUID: only the retry's IntegrityError is converted, and test_do_save_mappings_datastore_exception still passes unchanged.

How reviewers should test it.

uv run pytest test/bases/lif/identity_mapper_restapi/ test/components/lif/identity_mapper_storage_sql/ -q

To confirm the base test guards the wiring: remove only the @app.exception_handler(IdentityMappingConflictException) line, and the 409 test fails. The conflict falls through to the catch-all handler.

Related Issues

Closes #1261
Refs #1216
Refs #1131

Type of Change
  • Bug fix (non-breaking change which fixes an issue)
Project Area(s) Affected
  • bases/
  • components/
  • test/ or e2e/
  • API endpoints
  • Documentation (docs/, READMEs, ARCHITECTURE.md, CLAUDE.md)

Checklist
  • commit message follows commit guidelines (see commitlint.config.mjs)
  • tests are included (unit and/or integration tests)
  • code passes linting checks (uv run ruff check)
  • code passes formatting checks (uv run ruff format)
  • code passes type checking (uv run ty check)
  • pre-commit hooks have been run successfully
  • API changes: base README updated (bases/lif/identity_mapper_restapi/README.md). No page
    under docs/ documents the Identity Mapper's status codes, so there was nothing there to change
  • configuration changes: relevant folder README updated
Testing
  • Automated tests added/updated
Test main This branch
Base, end to end: a persistent collision through the real IdentityMapperService and IdentityMapperSqlStorage (aiosqlite) returns 409, a "may be retried" message, no code, and the pre-read ran exactly twice fails (assert 500 == 409) passes
Storage: save_mappings raises the conflict when the collision persists; still exactly two pre-reads fails (DataStoreException) passes
Storage: the single-mapping save_mapping path raises the conflict fails (DataStoreException) passes
Existing: a DataStoreException still returns 500 with a correlation UUID passes passes

The base test deliberately doesn't mock the service. As the issue warns, the ways this breaks sit between the layers: the storage brick's broad except swallowing the conflict, or the exception landing in the LIFException/catch-all handler. A test that stops at the exception type passes in both cases. Like #1260's tests, the collision is simulated with a patched pre-read that misses the committed row; no second transaction actually runs.

The identity-mapper base, service and storage suites pass (78 tests). pre-commit run --files on all eight changed files is green, including the full pytest suite.

Additional Notes

Shared bricks (#1171). identity_mapper_storage and identity_mapper_storage_sql are packaged only by lif_identity_mapper_api and lif_identity_mapper_mariadb. Only the API is a running service, and its workflow paths: already lists both bricks and the base (.github/workflows/lif_identity_mapper_api.yml:9-16).

Merge order. CHANGELOG.md is shared with #1310, #1311 and #1148: each adds a line at the top of [Unreleased] → ### Changed, and whichever merges later keeps both. #1274 touches the identity-mapper workflow files, not this code, so they don't conflict.

🤖 Generated with Claude Code

…ves the retry

A natural-key collision that survived #1260's one retry raised
DataStoreException and reached the caller as the generic 500 with a
correlation UUID, the same answer as a datastore outage. The retry's
IntegrityError now becomes IdentityMappingConflictException (defined in
identity_mapper_storage), both save paths re-raise it ahead of their broad
except, and the base answers 409 saying the request may be retried.

Co-Authored-By: Claude Opus 5.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

Status: Backlog

Development

Successfully merging this pull request may close these issues.

Identity Mapper: a natural-key collision that survives the retry should answer 409, not the generic 500

1 participant