Skip to content

Add POST /script/analyse for editor script validation - #717

Merged
Pasarus merged 4 commits into
mainfrom
add_script_analyse_route
Nov 11, 2026
Merged

Pasarus merged 4 commits into
mainfrom
add_script_analyse_route

Conversation

@Pasarus

@Pasarus Pasarus commented Nov 9, 2026 •

Copy link
Copy Markdown
Member

** DO NOT MERGE UNTIL THE OTHER PARTS ARE MERGED**

Closes # N/A — no issue; this is the FIA-API slice of the script-editor validation work.

Description

Adds POST /script/analyse, which forwards a script to
mantid-python-script-lsp and returns its
diagnostics, so the script editors can show real Python errors instead of accepting anything until a
reduction fails several minutes later. See fiaisis/mantid-python-script-lsp#1
for the analysis service itself; a frontend PR follows this one and lands against it.

No issue to link: this is the FIA-API slice of the script-editor validation work.

Only AnalyseRequest is modelled here. The response is forwarded as unmodelled JSON with its status
code intact, because the frontend branches on the status: a 503 from a cold service has to stay a 503
or the editors show a red error instead of "still starting up". Rejections FIA-API makes itself —
over-size, over rate limit — use the service's own {"detail": {"reason": ...}} shape so the browser
parses one thing whichever side refused.

The size cap counts UTF-8 bytes, not characters. len(content) disagrees with the service's
MAX_SCRIPT_BYTES for any script containing a non-ASCII character — a comment in Chinese or a unicode
em dash — and the two services refusing different scripts is worse than either cap being slightly
wrong. It is checked before Valkey or a worker is touched, and answered 413 in the service's shape.

The per-user rate limit is a fixed window in Valkey, so it survives replicas and restarts rather than
being per-pod memory. It fails open when Valkey is unavailable: this endpoint is advisory, and an
outage in the cache should not be able to take the editors down with it. 30 requests per 10 seconds per
user number leaves several times headroom over the frontend's 1 s debounce.

Only a well-formed 200 is cached, under SHA-256(content) with a 10 minute TTL. Caching a 503
would have every editor read a warm service as cold until the entry expired. A cache hit has its
content_hash replaced with the caller's own, because results are shared across users and the frontend
discards a response whose hash does not match the text on screen — without that, every cache hit would
be thrown away as stale.

The upstream call has a 5 s timeout and runs off the event loop, so a wedged worker delays one
advisory request rather than every endpoint in the pod. FIA-API answers 504 for its own timeout and
503 when the service is unreachable.

New env vars are documented in README.md: SCRIPT_LSP_URL, MAX_SCRIPT_BYTES,
SCRIPT_ANALYSE_CACHE_TTL_SECONDS, SCRIPT_ANALYSE_RATE_LIMIT_MAX_REQUESTS,
SCRIPT_ANALYSE_RATE_LIMIT_WINDOW_SECONDS. Only SCRIPT_LSP_URL needs setting per environment; the
defaults assume the in-cluster service DNS name.

positive_int_from_env is added to core/utility.py so that a typo in a deployment manifest falls back
to the default with a warning instead of failing at import: these settings gate optional behaviour, and
none of them is a reason for the API to refuse to start.

Deployment note: nothing here needs a new public route — /script/analyse is an ordinary FIA-API
endpoint, and mantid-python-script-lsp stays cluster-internal. Until that service is deployed,
SCRIPT_LSP_URL resolves to nothing and the route answers 503, which the frontend will treat as
"validation unavailable" rather than an error.

33 new tests in test/core/services/test_script_validation.py, including that the cap is bytes not
characters, that a 503 survives the trip, that a cached hit echoes the caller's hash, and that a
rejected JWT never reaches the analysis service. ruff check/ruff format clean, mypy --strict fia_api
clean, full unit suite green.

…-lsp

Frontend script editors need Python diagnostics that only a Mantid-aware
analysis service can give them, and the browser cannot hold a container. This
adds the FIA-API side of that path: an authenticated route that forwards a
script to mantid-python-script-lsp and hands the answer straight back.

- `AnalyseRequest` is the only model added; the response is forwarded as
  unmodelled JSON with its status code intact, so a 503 from a cold service
  stays a 503 and the editors can say "still starting up" instead of erroring.
- Rejections FIA-API makes itself (over-size, over rate limit) use the service's
  own `{"detail": {"reason": ...}}` shape so the frontend parses one thing.
- The size cap counts UTF-8 bytes, not characters, to match the service's
  `MAX_SCRIPT_BYTES`, and is refused before anything reaches Valkey or a worker.
- The per-user rate limit is a fixed window in Valkey, so it survives replicas
  and restarts, and fails open when Valkey is down: an advisory endpoint should
  not be able to take the editors down with it.
- Results are cached in Valkey under the script's SHA-256, and only a
  well-formed 200 is stored, so a warm service cannot be read as cold for the
  whole TTL. A cache hit echoes the caller's `content_hash`, which the cache
  cannot know because results are shared across users.
- The upstream call has a 5 s timeout and runs off the event loop.
- `positive_int_from_env` is added to `core/utility.py` so a typo in a manifest
  degrades to the default instead of crashing on import.
@Pasarus Pasarus added the AI Created with the assistance of AI label Nov 9, 2026
@codecov

codecov Bot commented Nov 9, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 97.17%. Comparing base (c8bfb58) to head (9095e4a).

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #717      +/-   ##
==========================================
+ Coverage   97.05%   97.17%   +0.12%     
==========================================
  Files          50       52       +2     
  Lines        2339     2442     +103     
==========================================
+ Hits         2270     2373     +103     
  Misses         69       69              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

The analysis settings were carried over as `*_ENV` plus `DEFAULT_*` name
constants read through a helper, which is how the analysis service configures
itself because that service must not crash at import. FIA-API reads config as a
one-liner, and no other module here defines an env-name constant, so the extra
globals were ceremony that made the settings harder to read rather than easier.

- Each setting is now a single line, with the variable named in the call that
  reads it. All five stay overridable, so nothing new has to be redeployed by
  rebuilding.
- `SCRIPT_LSP_URL` is read like `LLSP_API_HOST` already is, and
  `get_script_lsp_url` goes away; the trailing slash is still tolerated, since
  `//analyse` is a different path to anything downstream.
- The tests for `positive_int_from_env` move to `test/core/test_utility.py`,
  where the function under test lives.

Two fixes that only showed up against the versions `pyproject.toml` pins, which
this environment had drifted behind:

- `JWTAPIBearer` answers a request with no header at all with 401, not 403 (a
  present-but-invalid token is still a 403 from `authentication_error_handler`,
  which the test above the new one covers).
- redis 8 types `incr` as returning `int`, so the cast added for an older
  redis-py stub is now a `redundant-cast` error under `mypy --strict`.
`positive_int_from_env` existed to keep a typo in a manifest from stopping the
API starting. That is the wrong trade for a value like the size cap: a pod that
comes up with a cap of nothing, or a rate limit of one, quietly disagrees with
the analysis service about which scripts exist, and that is harder to notice than
a pod that does not come up at all.

- Each setting is now `int(os.environ.get(NAME, default))`, which is what
  `GITHUB_PACKAGES_CACHE_TTL_SECONDS` and the two cache TTLs in `routers/jobs.py`
  already do.
- The helper and its tests are gone, and `core/utility.py` is back to what it was.
- The effective settings are logged once at startup, since a value that did take
  effect is otherwise invisible until someone compares a rejection against a
  YAML file.
keiranjprice101
keiranjprice101 previously approved these changes Nov 9, 2026

@keiranjprice101 keiranjprice101 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This makes sense to me. Smart to cache on user number as my original instinct was "well there's only one per instrument anyway"

The line that prints what the process read from its environment never printed. It sat
at module scope, module imports run before `logging.basicConfig` in `fia_api.py`, and
a record emitted while there is no handler to receive it is dropped without trace --
`logging.lastResort` passes WARNING and above only. Found by running the built image
and looking for it in the container log, where it was absent.

This matters more here than for a typical informational line: a non-numeric
`SCRIPT_ANALYSE_CACHE_TTL_SECONDS` is made to raise on import rather than fall back,
so the log is where an operator confirms what a pod is actually running with, and
those values are set in a manifest several repositories away from this one.
@Pasarus
Pasarus merged commit 80e4199 into main Nov 11, 2026
10 checks passed
@Pasarus
Pasarus deleted the add_script_analyse_route branch November 11, 2026 09:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AI Created with the assistance of AI

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants