Skip to content

Issue #1292: fix: graphql_client raises when a 200 response carries GraphQL errors - #1310

Merged
bjagg merged 2 commits into
mainfrom
issue-1292-graphql-client-raises-on-errors
Sep 26, 2026
Merged

bjagg merged 2 commits into
mainfrom
issue-1292-graphql-client-raises-on-errors

Conversation

@dereck-symmetry

Copy link
Copy Markdown
Contributor
Description of Change

Problem. A GraphQL server answers HTTP 200 even when an operation fails, and reports the failure in the body's errors. Since #1264 / #1291, that's exactly how the LIF GraphQL API reports a Query Planner failure. graphql_client only called raise_for_status(), so an errors body 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 only errors is truthy) instead of raising a ToolError. The README already claimed the client raised on a "GraphQL-error response"; the code didn't.

Solution. graphql_query and graphql_mutation now call a small helper, _raise_on_graphql_errors, after the transport try. It raises GraphQLClientException("GraphQL errors: <messages>") whenever errors is non-empty. The helper sits outside the try so the generic except Exception doesn't re-wrap it as "GraphQL client error". Normal responses, and bodies with an empty errors: [], are returned unchanged.

The partial-success decision (the issue asked for it to be made deliberately). A body with both data and errors also raises. The only caller selects a single root field (person in GRAPH_QL_QUERY_TEMPLATE, updatePerson in 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 in lif_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_query and lif_mutation now report a GraphQL failure as a ToolError. Both already caught GraphQLClientException and converted it (semantic_search_service/core.py:431-433, :449-451), so they needed no code change. The error text is whatever GraphQL put in message. Since #1291 the query path sends only Query 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, not errors.

How reviewers should test it.

uv run pytest test/components/lif/graphql_client/ -q

To confirm the new tests actually guard the change: revert components/lif/graphql_client/core.py alone and three of them fail.

Related Issues

Closes #1292
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

Four new tests in test/components/lif/graphql_client/test_core.py:

Test Old code New code
query, errors with data: null fails (did not raise) passes
query, errors with partial data fails (did not raise) passes
mutation, errors with data: null fails (did not raise) passes
query, errors: [] is returned unchanged (guards against over-raising) passes passes

The client and consumer suites (graphql_client, semantic_search_service, semantic_search_mcp_server) pass: 29 tests. pre-commit run --files on all four changed files is green, including the full pytest suite.

Additional Notes

Shared brick (#1171). components/lif/graphql_client is packaged only by lif_semantic_search_mcp_server (projects/lif_semantic_search_mcp_server/pyproject.toml:36), and that project's deploy workflow already lists components/lif/graphql_client/** in its paths: (.github/workflows/lif_semantic_search_mcp_server.yml:14). A repo-wide search finds semantic_search_service as 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

…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 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. 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.

@bjagg
bjagg merged commit e55bb71 into main Sep 26, 2026
4 checks passed
@bjagg
bjagg deleted the issue-1292-graphql-client-raises-on-errors branch September 26, 2026 01:28
bjagg added a commit that referenced this pull request Sep 26, 2026
Resolve CHANGELOG.md with #1310 by keeping both entries.
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>
bjagg added a commit that referenced this pull request Sep 26, 2026
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 added a commit that referenced this pull request Sep 26, 2026
…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>
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_client treats a 200 response carrying GraphQL errors as success

2 participants