Skip to content

Issue #1309: fix: keep the Query Planner's error body out of GraphQL mutation errors - #1311

Merged
bjagg merged 2 commits into
mainfrom
issue-1309-mutation-error-body-leak
Sep 26, 2026
Merged

bjagg merged 2 commits into
mainfrom
issue-1309-mutation-error-body-leak

Conversation

@dereck-symmetry

Copy link
Copy Markdown
Contributor
Description of Change

Problem. The GraphQL update mutation raised Exception(f"Mutation failed: {response.status_code}: {response.text}") (components/lif/openapi_to_graphql/type_factory.py:991). Strawberry puts that message into the errors entry the caller receives. The Query Planner's /update handler builds its 500 body from str(e) of any exception it catches (bases/lif/query_planner_restapi/core.py:287-288). So whatever a backend exception said (a driver message, a host, a username) reached whoever held a GraphQL API key.

This is the leak bjagg measured on the query path in #1291, where a credential-shaped 500 reached the caller as Query failed: 500: {"detail":"FATAL: password authentication failed for user \"lifadmin\" host=10.0.3.17"}. His approval left the mutation's copy as a follow-up, filed as #1309.

Solution. The same one-line change #1291 made to the query path: drop : {response.text} from the raised message. The logger.error line keeps the full body for operators, and a comment points back at the query path. The caller still gets the status (Mutation failed: 500), so a failed mutation stays clearly a failure.

Side effects. Only the error text changes. The MCP lif_mutation tool passes on the GraphQL error, so its tool error gets shorter. The body is no longer available to the caller for debugging; it's in the GraphQL server log instead.

How reviewers should test it.

uv run pytest test/components/lif/openapi_to_graphql/test_core.py -q -k MutationFailure

To confirm the test guards the change: put : {response.text} back into the raise and the test fails on assert "mongodb-org1" not in result.errors[0].message.

Related Issues

Closes #1309
Refs #1131

Type of Change
  • Bug fix (non-breaking change which fixes an issue)
Project Area(s) Affected
  • components/
  • test/ or e2e/
  • 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
  • configuration changes: relevant folder README updated
Testing
  • Automated tests added/updated

TestMutationFailureDoesNotLeakBody builds a real schema with a mutable field, stubs httpx.AsyncClient with the same fake the #1264 tests use, and runs updatePerson against a 500 whose body names mongodb-org1:27017. It asserts that the caller gets an error, that the error includes 500, and that the host name isn't in it.

  • On main it fails on the host assertion (the message was Mutation failed: 500: {"detail":"connection refused: mongodb-org1:27017"}), having already passed the schema build and the 500 check. So it fails for the right reason.
  • With the fix it passes, along with the rest of test/components/lif/openapi_to_graphql/.
  • pre-commit run --files on all four changed files is green, including the full pytest suite.

The api_graphql README's error contract now says the message carries only the status, and that the Query Planner body goes to the server log. There's also a CHANGELOG entry.

Additional Notes

Shared brick (#1171). components/lif/openapi_to_graphql is packaged by lif_graphql_api only, and the GraphQL deploy workflow already covers it (the #1291 and #1301 merges both redeployed GraphQL).

Merge order. This PR, #1310 and #1148 each add a line at the top of CHANGELOG.md's [Unreleased] → ### Changed list. Whichever merges later keeps both lines. There's no other file overlap with open PRs.

🤖 Generated with Claude Code

…mutation errors

The update mutation raised with the Query Planner's raw error body, which
Strawberry relays to the caller verbatim, and the QP's /update builds that
body from str(e) of any exception. The caller now gets only the status
(`Mutation failed: <status>`); the body stays in the server log, matching
what #1291 did for the query path.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@dereck-symmetry

Copy link
Copy Markdown
Contributor Author

Merge-order note: #1314 (#1293) removes the module-level type_factory.input_type_cache, and this PR's new TestMutationFailureDoesNotLeakBody resets it with monkeypatch.setattr(type_factory, "input_type_cache", {}). Measured by a trial merge: test_core.py conflicts textually (both append a class at the end, so keep both), and once resolved this test fails with AttributeError: ... has no attribute 'input_type_cache'. Whichever of the two merges second should also delete that one line; the brick then passes (31 in the trial). Details in the #1314 description.

@bjagg bjagg left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approving: the change is correct and mirrors #1291. 30 pass; putting response.text back fails the new test, and dropping the status is caught too.

Two non-blocking notes. (1) Nothing guards "the body is still logged": removing the logger.error line passes all 30. Same gap as the query-path test, so consistent, just unguarded. (2) _FakeResponse keeps text and json() independent, so a leak via response.json() would pass these tests. It's fine against real httpx, but deriving json() from text in the fake would close it.

bjagg added a commit that referenced this pull request Sep 26, 2026
… one schema build (#1314)

##### Description of Change

**Problem.** `components/lif/openapi_to_graphql/type_factory.py`
declared `input_type_cache` at **module level**, keyed by type name
alone, and nothing ever cleared it. So the second schema built in a
process reused the first schema's filter input types. Two schemas whose
`Person` had entirely different queryable fields silently shared one
`PersonInput`. `mutable_input_type_cache`, its sibling, was already per
build.

As #1293 says, this **isn't reachable in production today**:
`bases/lif/api_graphql/core.py` builds the schema once, in `lifespan`.
It did already bite tests, though. Two test helpers carried a
`monkeypatch.setattr(type_factory, "input_type_cache", {})` workaround,
and without it a test passed alone but failed in the suite. It would
also turn any future schema hot-reload after an MDR change into a silent
bug.

**Solution.** This is the issue's suggested change.
`generate_graphql_root_types` (`core.py`) now creates `input_type_cache
= {}` next to `mutable_input_type_cache` and passes it to
`create_input_type`. That gains an `input_type_cache` parameter,
matching `create_mutable_input_type`. The module-level variable is gone.
`create_input_type` isn't exported from the package (`__init__.py`
exports `generate_graphql_root_types` / `generate_graphql_schema`), and
`core.py` is its only caller.

Both test workarounds are removed: the one the issue names in
`test_core.py::TestQueryPlannerFailureReachesCaller`, and a second copy
in `test_lif_client_header.py` that #1301 added. Their `_schema` helpers
no longer take `monkeypatch`.

**Side effects.** None in production. The service builds one schema per
process, and it's built the same way.

**How reviewers should test it.**

```bash
uv run pytest test/components/lif/openapi_to_graphql/ -q
```

##### Related Issues

Closes #1293
Refs #1131

##### Type of Change

- [x] Bug fix (non-breaking change which fixes an issue)

##### Project Area(s) Affected

- [x] components/
- [x] test/ or e2e/

---

##### Checklist

- [x] commit message follows commit guidelines (see
commitlint.config.mjs)
- [x] tests are included (unit and/or integration tests)
- [x] code passes linting checks (`uv run ruff check`)
- [x] code passes formatting checks (`uv run ruff format`)
- [x] code passes type checking (`uv run ty check`)
- [x] pre-commit hooks have been run successfully

##### Testing

- [x] Automated tests added/updated

`TestInputTypesArePerSchemaBuild` builds two schemas in one process: one
where `Person` has a single queryable field `alpha`, and one where it's
`beta`. It reads each schema's `PersonInput` fields by introspection.
- On `main` it **fails** exactly as the issue describes: `assert
{'alpha'} == {'beta'}`. The second schema reused the first's input type.
- With the fix it passes.

The removed workarounds are the third acceptance criterion. With them
gone, all 30 tests in `test/components/lif/openapi_to_graphql/` pass in
the same run, including the ones that used to need them. `pre-commit run
--files` on all four changed files is green, including the full pytest
suite.

No README or CHANGELOG change: the component README doesn't mention the
cache, and nothing changes for a caller.

##### Additional Notes

**Merge order with #1311 (#1309), measured by a trial merge.** #1311's
new `TestMutationFailureDoesNotLeakBody` carries a third copy of the
same workaround line.

1. **Textual conflict in `test_core.py`:** both PRs append a test class
at the end of the file. Keep both classes.
2. **Then, a runtime failure git can't see:** once resolved, #1311's
test fails with `AttributeError: <module
'lif.openapi_to_graphql.type_factory'> has no attribute
'input_type_cache'`, because `monkeypatch.setattr` refuses a missing
attribute. **Delete that one line** (`monkeypatch.setattr(type_factory,
"input_type_cache", {})`) and the brick passes (31 passed in the trial).

Whichever PR merges second needs both steps. `main` requires branches to
be up to date, so that PR's CI will surface step 2 if it's missed.

**Shared brick (#1171).** `components/lif/openapi_to_graphql` is
packaged by `lif_graphql_api` only, and its deploy workflow already
covers the brick.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: dereck <dereck.haskins@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Benito Gonzalez <bgonzalez@unicon.net>
bjagg added a commit that referenced this pull request Sep 26, 2026
…umn with a 422 (#1313)

##### Description of Change

**Problem.** `IdentityMapping`'s string fields have no `max_length`. As
#1300 describes, a value longer than its column reaches MariaDB, fails
with `1406 Data too long` in strict mode, and returns to the caller as a
generic 500 through `DataStoreException`. #1258 lowered the threshold
for three fields, from 255 to 191.

**Solution: enforce the widths at the API boundary.** The issue left the
placement open; this puts it at the API boundary rather than on the
shared DTO.

- A new `IdentityMappingRequest(IdentityMapping)` in
`bases/lif/identity_mapper_restapi/core.py` is the `POST .../mappings`
body type. Each of the five string fields carries `max_length` **read
from the SQLAlchemy column**
(`IdentityMappingModel.__table__.c[name].type.length`). The model
already mirrors `02-ddl.sql`, so there's no third copy of the numbers.
- The endpoint converts the validated items back to plain
`IdentityMapping` before calling the service. The request model only
validates; the service and storage receive exactly what they did before.
This also keeps `list[IdentityMapping]` type-correct for `ty` (`list` is
invariant) without widening the service's signature.
- An oversized field now gets FastAPI's standard `422`, with `loc`
naming it, e.g. `["body", 0, "target_system_id"]`.

**Why not `max_length` on the DTO in `components/lif/datatypes`.** Only
the Identity Mapper bricks use `IdentityMapping`, but `datatypes` is
packaged by 12 projects and watched by 9 deploy workflows. Putting it
there would redeploy about nine services for a class one of them uses,
and it would be a third copy of the widths. Here, only the Identity
Mapper redeploys.

**Limitations.** `max_length` counts characters, the same unit as
MariaDB's `VARCHAR(n)`. The 500 this replaces is as the issue describes
it; I didn't reproduce it against a real MariaDB for this PR. The tests
prove the 422 at the API, which is the part this change owns. The
`GET`/`DELETE` path parameters aren't limited: an over-long one just
matches no row.

**How reviewers should test it.**

```bash
uv run pytest test/bases/lif/identity_mapper_restapi/ -q -k column
```

##### Related Issues

Closes #1300
Refs #1131

##### Type of Change

- [x] Bug fix (non-breaking change which fixes an issue)

##### Project Area(s) Affected

- [x] bases/
- [x] test/ or e2e/
- [x] API endpoints
- [x] Documentation (docs/, READMEs, ARCHITECTURE.md, CLAUDE.md)

---

##### Checklist

- [x] commit message follows commit guidelines (see
commitlint.config.mjs)
- [x] tests are included (unit and/or integration tests)
- [x] code passes linting checks (`uv run ruff check`)
- [x] code passes formatting checks (`uv run ruff format`)
- [x] code passes type checking (`uv run ty check`)
- [x] pre-commit hooks have been run successfully
- [x] API changes: base README updated
(`bases/lif/identity_mapper_restapi/README.md`). No page
      under `docs/` documents the Identity Mapper's status codes

##### Testing

- [x] Automated tests added/updated

Two tests, each parametrized over all five fields, with the widths
pinned as literals from the issue's table (191 / 191 / 191 / 100 / 255).
A width change then shows up as a deliberate test edit.

| Test | `main` | This branch |
|---|---|---|
| One character over the width → `422`, error `loc` is exactly `["body",
0, <field>]`, service never called | **fails** ×5 (reaches the service,
200) | passes ×5 |
| Exactly at the width → `200`, service receives a plain
`IdentityMapping` | passes ×5 | passes ×5 |

With only `core.py` reverted to `main`, the five "over" cases fail
again. The three existing save tests, which compare the service's call
arguments against `IdentityMapping(...)`, pass **unchanged**, which
confirms the service still gets the plain DTO. The identity-mapper base,
service and storage suites pass (86 tests). `pre-commit run --files` on
all four changed files is green, including the full pytest suite.

##### Additional Notes

**Merge order with #1312** (#1261, the 409 on a persistent collision). I
trial-merged the two branches:
- `core.py` and the test file **merge cleanly**.
- **`CHANGELOG.md`** and
**`bases/lif/identity_mapper_restapi/README.md`** conflict: both PRs add
a paragraph at the same spot. Keep both.

`CHANGELOG.md` is also shared with #1310, #1311 and #1148, under the
same keep-both rule.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: dereck <dereck.haskins@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Benito J. Gonzalez <bgonzalez@unicon.net>
Resolve CHANGELOG.md with #1310 and #1313 by keeping every entry. In
test_core.py, keep main's file plus this branch's
TestMutationFailureDoesNotLeakBody, minus its
monkeypatch.setattr(type_factory, "input_type_cache", {}) line: #1314
removed that module attribute, so the line raised AttributeError (the
step #1314's PR body describes).
@bjagg
bjagg merged commit 8a4d944 into main Sep 26, 2026
4 checks passed
@bjagg
bjagg deleted the issue-1309-mutation-error-body-leak branch September 26, 2026 01:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

GraphQL: the update mutation relays the Query Planner's raw error body to the caller

2 participants