Issue #1292: fix: graphql_client raises when a 200 response carries GraphQL errors - #1310
Conversation
…raphQL errors A GraphQL server answers 200 for a failed operation and reports it in the body's `errors`, which is how the LIF GraphQL API reports a Query Planner failure since #1264. raise_for_status() let that through as data, so the semantic search MCP tools handed a failure to the LLM as a result. Both graphql_query and graphql_mutation now raise GraphQLClientException when `errors` is non-empty, including alongside partial `data`: the only caller selects one root field, so a partial answer means a nested field failed, and returning it would read as absent data. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
bjagg
left a comment
There was a problem hiding this comment.
Approving. I checked the claims against the code: the consumer converts GraphQLClientException to ToolError on both tools (and its generic except covers anything else), both templates select a single root field, so the partial-success decision holds, semantic_search_service is the only importer, and the paths: coverage is as stated. 29 tests pass; reverting core.py fails exactly the 3 new ones.
I mutation-tested it: skipping the check on either path, returning partial data, over-raising on errors: [], and dropping the messages are all caught. One nit, not blocking: moving _raise_on_graphql_errors back inside the try passes all 29, because the match= patterns are substrings that survive the re-wrap. The outside-the-try placement the description calls out is correct but unguarded. match=r"^GraphQL errors: " on one of the tests would pin it.
Resolve CHANGELOG.md with #1310 by keeping both entries.
…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).
…mutation errors (#1311) ##### 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.** ```bash 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 - [x] Bug fix (non-breaking change which fixes an issue) ##### Project Area(s) Affected - [x] components/ - [x] test/ or e2e/ - [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] configuration changes: relevant folder README updated ##### Testing - [x] 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](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>
Description of Change
Problem. A GraphQL server answers HTTP
200even when an operation fails, and reports the failure in the body'serrors. Since #1264 / #1291, that's exactly how the LIF GraphQL API reports a Query Planner failure.graphql_clientonly calledraise_for_status(), so anerrorsbody came back as data. Its one consumer,semantic_search_service, then passed it to the LLM as a tool result ([response_json] if response_json else ..., and a body containing onlyerrorsis truthy) instead of raising aToolError. The README already claimed the client raised on a "GraphQL-error response"; the code didn't.Solution.
graphql_queryandgraphql_mutationnow call a small helper,_raise_on_graphql_errors, after the transporttry. It raisesGraphQLClientException("GraphQL errors: <messages>")whenevererrorsis non-empty. The helper sits outside thetryso the genericexcept Exceptiondoesn't re-wrap it as "GraphQL client error". Normal responses, and bodies with an emptyerrors: [], are returned unchanged.The partial-success decision (the issue asked for it to be made deliberately). A body with both
dataanderrorsalso raises. The only caller selects a single root field (personinGRAPH_QL_QUERY_TEMPLATE,updatePersonin the mutation template). So a partial answer can only mean a nested field failed, and returning the rest would let that failure read as data the learner doesn't have, which is the ambiguity #1264 removed. This matches the existing precedent inlif_to_lif_adapter/adapter.py:73, which raises on any"errors" in result. The decision and its reasoning are written down in the component README.Side effects. The semantic search MCP tools
lif_queryandlif_mutationnow report a GraphQL failure as aToolError. Both already caughtGraphQLClientExceptionand converted it (semantic_search_service/core.py:431-433,:449-451), so they needed no code change. The error text is whatever GraphQL put inmessage. Since #1291 the query path sends onlyQuery failed: <status>. The mutation path still includes the Query Planner body; that's a separate leak, tracked as #1309.This doesn't interact with #1308. The partial-answer reason planned there goes in the response
extensions, noterrors.How reviewers should test it.
To confirm the new tests actually guard the change: revert
components/lif/graphql_client/core.pyalone and three of them fail.Related Issues
Closes #1292
Refs #1131
Type of Change
Project Area(s) Affected
Checklist
uv run ruff check)uv run ruff format)uv run ty check)Testing
Four new tests in
test/components/lif/graphql_client/test_core.py:errorswithdata: nullerrorswith partialdataerrorswithdata: nullerrors: []is returned unchanged (guards against over-raising)The client and consumer suites (
graphql_client,semantic_search_service,semantic_search_mcp_server) pass: 29 tests.pre-commit run --fileson all four changed files is green, including the full pytest suite.Additional Notes
Shared brick (#1171).
components/lif/graphql_clientis packaged only bylif_semantic_search_mcp_server(projects/lif_semantic_search_mcp_server/pyproject.toml:36), and that project's deploy workflow already listscomponents/lif/graphql_client/**in itspaths:(.github/workflows/lif_semantic_search_mcp_server.yml:14). A repo-wide search findssemantic_search_serviceas the only importer.Merge order. The only open PR sharing a file with this one is #1148, on
CHANGELOG.md. Both add lines under[Unreleased], so whichever merges second keeps both.🤖 Generated with Claude Code