Skip to content

HTTP server that exposes stores, arrays, groups - #3732

Open
d-v-b wants to merge 39 commits into
zarr-developers:mainfrom
d-v-b:feat/experimental-server
Open

HTTP server that exposes stores, arrays, groups#3732
d-v-b wants to merge 39 commits into
zarr-developers:mainfrom
d-v-b:feat/experimental-server

Conversation

@d-v-b

@d-v-b d-v-b commented Feb 28, 2026

Copy link
Copy Markdown
Contributor

This PR adds an experimental http server in experimental.serve. This server can expose stores over http. It can also expose arrays and groups over http. Exposing a store means exposing the entire key: value space of the store. Exposing an array means only exposing the metadata + chunks. Exposing a group means only exposing sub-groups and sub-arrays. See #3731 for more on this distinction.

The server is an optional dependency implemented via starlette. It handles byte-range reads and other http methods. CORS headers and allowed methods can be configured. I'm considering handling prefix requests like foo/bar by returning a simple HTML document that lists the visible keys under foo/bar/, for user-friendliness and to aid httpstore readers that use such responses for listing contents. I'd also like to implement convenience functions for kicking off a server from jupyter and a CLI.

Opening as a draft while I work on this.

@github-actions github-actions Bot added the needs release notes Automatically applied to PRs which haven't added release notes label Feb 28, 2026
@d-v-b
d-v-b marked this pull request as ready for review March 1, 2026 20:10
Comment thread tests/test_examples.py
PEP_723_REGEX: Final = r"(?m)^# /// (?P<type>[a-zA-Z0-9-]+)$\s(?P<content>(^#(| .*)$\s)+)^# ///$"

# This is the absolute path to the local Zarr installation. Moving this test to a different directory will break it.
ZARR_PROJECT_PATH = Path(".").absolute()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

changes in this file are simplifications to our examples testing infrastructure. Instead of re-writing the script header, we just override the declared zarr dep in the invocation of uv run ...

@github-actions github-actions Bot removed the needs release notes Automatically applied to PRs which haven't added release notes label Mar 1, 2026
@@ -0,0 +1,178 @@
"""Utilities for determining the set of valid store keys for zarr nodes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

we eventually need to find a more natural place for this code. I'm not sure which module it should live in.

@d-v-b
d-v-b requested a review from maxrjones March 1, 2026 20:29
@d-v-b

d-v-b commented Mar 1, 2026

Copy link
Copy Markdown
Contributor Author

I'm considering handling prefix requests like foo/bar by returning a simple HTML document that lists the visible keys under foo/bar/, for user-friendliness and to aid httpstore readers that use such responses for listing contents. I'd also like to implement convenience functions for kicking off a server from jupyter and a CLI.

Didn't end up doing these things. We can add them later if people are interested.

@d-v-b
d-v-b requested a review from a team March 2, 2026 00:17
@d-v-b

d-v-b commented Mar 2, 2026

Copy link
Copy Markdown
Contributor Author

here's a demo of this functionality:

# /// script
# requires-python = ">=3.11"
# dependencies = [
#   "zarr[server] @ git+https://github.com/d-v-b/zarr-python@feat/experimental-server",
# ]
# ///

import zarr
import numpy as np
from zarr.storage import MemoryStore
from zarr.experimental.serve import serve_node

data = np.arange(1000, dtype='uint8').reshape(10, 10, 10)
store = MemoryStore({})
z = zarr.create_array(store, data=data, write_data=True)
serve_node(
    z,
    host="127.0.0.1",
    port=8000,
    cors_options={"allow_origins": ["*"], "allow_methods":["GET", "HEAD"]}
    )

If you run that script, then visit https://neuroglancer-demo.appspot.com/#!%7B%22dimensions%22:%7B%22dim_0%22:%5B1%2C%22%22%5D%2C%22dim_1%22:%5B1%2C%22%22%5D%2C%22dim_2%22:%5B1%2C%22%22%5D%7D%2C%22position%22:%5B1.5%2C4.5%2C5.5%5D%2C%22crossSectionScale%22:0.03421811831166599%2C%22projectionOrientation%22:%5B-0.06506630778312683%2C-0.14447830617427826%2C-0.003086749231442809%2C0.9873615503311157%5D%2C%22projectionScale%22:53.328548429788874%2C%22layers%22:%5B%7B%22type%22:%22image%22%2C%22source%22:%22http://127.0.0.1:8000/%7Czarr3:%22%2C%22tab%22:%22source%22%2C%22name%22:%228000%22%7D%5D%2C%22selectedLayer%22:%7B%22visible%22:true%2C%22layer%22:%228000%22%7D%2C%22layout%22:%224panel-alt%22%7D, you should see the array data

@dcherian

dcherian commented Mar 2, 2026

Copy link
Copy Markdown
Contributor

Does this really need to belong in the core python library? Is there any advantage to experimenting with it in this repository?

@d-v-b

d-v-b commented Mar 2, 2026

Copy link
Copy Markdown
Contributor Author

Does this really need to belong in the core python library? Is there any advantage to experimenting with it in this repository?

IMO yes.

  • if we ever need to test an HTTP store, we would need a way to expose a zarr store over HTTP, e.g. the stuff in this PR.
  • many zarr datasets are too big to visualize with tools like matplotlib. This tool makes it easy to expose Zarr data to web-based visualization tools like neuroglancer, fulfilling a basic need for zarr users.

@d-v-b

d-v-b commented Mar 2, 2026

Copy link
Copy Markdown
Contributor Author

and putting this in experimental is low commitment. if nobody uses this feature and it's a development burden, we can remove it. If on the other hand it's actually useful, we can keep it.

@d-v-b

d-v-b commented Mar 3, 2026

Copy link
Copy Markdown
Contributor Author

another important use case: in zarr-python today, if you create a custom store, there is currently no way in zarr python to expose that store as a writable endpoint to a zarr-aware client. This PR enables this functionality.

@psobolewskiPhD

Copy link
Copy Markdown

Love this, as someone who regularly uses @manzt https://github.com/manzt/simple-zarr-server
@kephale also has an implementation in a script!
Super handy to be able to do visualization of remote data, particularly in headless environments.

@d-v-b

d-v-b commented Mar 3, 2026

Copy link
Copy Markdown
Contributor Author

thanks @psobolewskiPhD, given your experience with other tools let me know if there are any features missing from this PR and I can add them.

@dcherian

dcherian commented Mar 3, 2026

Copy link
Copy Markdown
Contributor

many zarr datasets are too big to visualize with tools like matplotlib.

I don't think this isn't a good argument. many zarr datasets are too big to compute on; should we vendor cubed/dask too?

That said, i don't plan on helping maintain it so ... no skin off my back hehe

@d-v-b

d-v-b commented Mar 3, 2026

Copy link
Copy Markdown
Contributor Author

I don't think this isn't a good argument. many zarr datasets are too big to compute on; should we vendor cubed/dask too?

I think visualizing zarr data is far more basic than doing distributed compute on it. It's very common to use data visualization as a basic sanity check when reading or writing data. And the server is less than 500 lines of code. I have not checked but I suspect this is a bit smaller than dask or cubed.

@psobolewskiPhD

Copy link
Copy Markdown

Very cool! Works quite nicely!
One nice thing that simple-zarr-server offers is a name.

serve(store, host="0.0.0.0", name='data.zarr')  # pass name so reader works

then this works from my CLI:

napari http://URL:8000/data.zarr

(napari readers use extensions)

What's cool is this worked with tifffile as_zarr!
image

@d-v-b

d-v-b commented Mar 4, 2026

Copy link
Copy Markdown
Contributor Author

Very cool! Works quite nicely! One nice thing that simple-zarr-server offers is a name.

serve(store, host="0.0.0.0", name='data.zarr')  # pass name so reader works

Good idea, I can add this

@psobolewskiPhD

psobolewskiPhD commented Mar 4, 2026

Copy link
Copy Markdown

I did run into one issues, maybe PBCAK, but when the remote file being served was zarr2, if I didn't specify zarr_version=2 in my local (client) zarr.open things didn't work -- i couldn't get the arrays from a group. I happened to know it was zarr2, but in principle that might not be the case?

d-v-b added 4 commits July 20, 2026 15:53
starlette 1.3 deprecated using httpx with its TestClient, which the
warnings-as-errors filter turns into a collection error; add httpx2 to
the test dependency group. uvicorn 0.51 removed
Server.install_signal_handlers and now skips signal-handler setup off
the main thread natively, so drop the monkey-patch workaround.

Assisted-by: ClaudeCode:claude-fable-5
@d-v-b

d-v-b commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

I'm on board with adding this to experimental, especially given the positive responses in this thread/on zulip.

I have a couple questions:

1. We'll find out if something's broken via issues, but how will we find out if people love the feature such that it should be moved into the stable API? Should we include an ExperimentalWarning that links to a discussion where people can comment and advocate for the feature's elevation?

2. Should we keep the functionality in `core.keys` inside src/zarr/experimental/ for now, until it's used by part of the stable functionality? I'd want it to be as simple as possible to yank this stuff out if we decide not to keep it, so to me it seems helpful to keep experimental internals isolated.

What if we sidestep these concerns entirely by putting the server in a separate package, e.g. zarr-server, that depends on zarr-python?

@codecov

codecov Bot commented Jul 28, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.91%. Comparing base (0b72757) to head (6f676bc).
⚠️ Report is 3 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3732      +/-   ##
==========================================
+ Coverage   93.87%   93.91%   +0.04%     
==========================================
  Files          91       91              
  Lines       12594    12625      +31     
==========================================
+ Hits        11822    11857      +35     
+ Misses        772      768       -4     
Files with missing lines Coverage Δ
src/zarr/core/chunk_key_encodings.py 97.18% <100.00%> (+6.00%) ⬆️

... and 3 files with indirect coverage changes

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

Assisted-by: ClaudeCode:claude-fable-5
@d-v-b

d-v-b commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

my current inclination to close this out is to add a new python package called zarr-server in this repo, in packages/zarr-server. zarr-server will depend on zarr-python, and soon zarr-metadata; zarr-python will know nothing about it.

d-v-b added 11 commits July 28, 2026 15:00
The moved code now uses only public zarr API: zarr.buffer.cpu replaces
zarr.core.buffer.cpu, spec-defined key names are inlined, chunk-key
encodings are duck-typed on their spec names, and the shard grid shape is
computed locally from public Array attributes.

Also mirrors the root repo's [tool.numpydoc_validation] override in the
package's pyproject.toml, since numpydoc-validation resolves config from
the nearest pyproject.toml and would otherwise apply its stricter default
checks to the moved docstrings.

Assisted-by: ClaudeCode:claude-fable-5
…t the core fix

Assisted-by: ClaudeCode:claude-fable-5
The server now lives in packages/zarr-server (published as zarr-server).
The feature never shipped in a zarr release, so there is no deprecation
shim. The decode_chunk_key strictness fix stays in zarr core.

Assisted-by: ClaudeCode:claude-fable-5
Assisted-by: ClaudeCode:claude-fable-5
…removal

The package lock embeds the workspace-root zarr project's metadata; Task 4
removed the root server extra and httpx2 test dep after this lock was first
generated.

Assisted-by: ClaudeCode:claude-fable-5
store_app (and thus serve_store) passed the request path straight to
the store without validation, since the is_valid_node_key gate only
ran for node_app. Starlette percent-decodes path params, so a request
like GET /..%2fsecret.txt arrived as literal ".." and LocalStore
resolved it outside the store root, allowing arbitrary file read via
GET and arbitrary file write via PUT. Closes this path-traversal
vulnerability by rejecting any "." or ".." path segment in
_handle_request before the store is touched, for both store_app and
node_app.

Assisted-by: ClaudeCode:claude-fable-5
… guard

The path-traversal guard in _handle_request only rejected "." and ".."
segments. A percent-encoded leading slash (e.g. "/%2fetc%2fhostname")
decodes to an absolute path param ("/etc/hostname"), whose split()
produces an empty leading segment with no "." or ".." segment, so the
guard let it through. LocalStore resolves an absolute key by discarding
its configured root, allowing arbitrary filesystem read/write outside
the store. Reject empty segments too, closing the bypass.

Assisted-by: ClaudeCode:claude-fable-5
Keeps the zarr-server release workflow's pinned action SHAs in sync with
the dependabot bump that landed in zarr-metadata-release.yml via the merge.

Assisted-by: ClaudeCode:claude-fable-5
…d keys

The traversal guard in _handle_request split only on "/", so backslash-
separated segments like "..\\..\\win.ini" and drive-qualified or
UNC-rooted keys like "C:/Windows/win.ini" or "\\host\share\x" passed
through untouched. On Windows, LocalStore joins keys onto its root via
pathlib, which discards the root entirely for a drive-qualified or
rooted key -- turning percent-encoded backslash paths into arbitrary
file read/write.

Fold backslashes to "/" before the segment check (mirroring zarr's own
normalize_path) and add an ntpath.splitdrive check to catch drive
letters and UNC prefixes that don't produce empty/"."/".." segments.

Added TDD coverage that fails against the old guard (via a store double
that raises if get/set is ever called) and passes against the fix.

Assisted-by: ClaudeCode:claude-fable-5
The `/..%2f..%2fsecret.txt` case in test_encoded_traversal_variants_
return_404 climbed two levels from tmp_path/store_root to tmp_path/..,
but the secret was written at tmp_path/secret.txt (one level up) --
so the case passed for the wrong reason (no such file, not "guard
blocked it") even with the traversal guard deleted entirely.

Nest the store root exactly `climb_depth` directories below tmp_path
per case, so every case's ".." segments resolve to tmp_path/secret.txt.

Verified by mutation: copied _serve.py to a scratch dir (never the
repo), deleted the guard body, and ran TestPathTraversalProtection
against it via PYTHONPATH. Before this fix, 12/13 traversal tests
failed against the mutant (1 false pass -- this vacuous case). After
this fix, 13/13 fail against the mutant, and all still pass against
the real guard.

Assisted-by: ClaudeCode:claude-fable-5
@psobolewskiPhD

Copy link
Copy Markdown

Sounds good to me!
Makes sense as an optional addon.

d-v-b added 9 commits July 28, 2026 20:39
… forever

shutdown() joined the server thread with no timeout, and uvicorn's
graceful wait is itself unbounded: force_exit is only set by a signal
handler uvicorn deliberately skips off the main thread. A client mid
request could wedge __exit__ unrecoverably.

Bound uvicorn's graceful wait with timeout_graceful_shutdown and fall
back to force_exit if the thread outlives it, both driven by a new
shutdown_timeout parameter on serve_store/serve_node.

Assisted-by: ClaudeCode:claude-fable-5
_handle_request special-cased PUT and let every other verb fall through
to the read path, so a server built with DELETE/POST/PATCH answered them
with the key's contents and changed nothing. HTTPMethod advertised all
of them.

Narrow HTTPMethod to GET/PUT/HEAD and reject anything else when the app
is built. Doing this before the first release avoids narrowing a
published type later.

Assisted-by: ClaudeCode:claude-fable-5
… in CI

The README's round-trip example reads back through FsspecStore, which
needs an HTTP-capable fsspec that zarr-server does not depend on, so a
clean install failed on the PyPI landing page's headline snippet. Same
for examples/serve.py under a plain interpreter, which needs httpx.

check_changelogs.yml also never visited packages/zarr-server/changes, so
a malformed fragment would have passed PR CI and failed at release time.

Assisted-by: ClaudeCode:claude-fable-5
…nd-trip

Reading a served array back with zarr.open_array(url) routes through
FsspecStore -- zarr's only URL-string backend -- so the README's headline
example needs an HTTP-capable fsspec that the package itself has no
reason to depend on. Carry that in a docs group, mirroring the root
project's group for running examples, and use it to test the round-trip
in CI so the example cannot rot.

Assisted-by: ClaudeCode:claude-fable-5
The package is HTTP-specific end to end -- Starlette/ASGI, byte-range
headers, CORS, HTTP verbs -- so the unqualified name overclaimed its
scope and squatted the generic name that a future transport (an
S3-compatible or WebDAV frontend) would want.

Renames the distribution, the zarr_http_server module, the package
directory, the zarr_http_server-v* release tags, both workflows and
their artifact and PyPI environment names. Nothing is published yet, so
this costs nothing now and would be permanent after the first upload.

Assisted-by: ClaudeCode:claude-fable-5
… open

Starlette's Route treats a falsy `methods` as "match every method", and
an empty set passes the unsupported-verb check trivially, so
store_app(store, methods=set()) served GET, DELETE, POST, PATCH and
TRACE alike and accepted PUT writes -- the opposite of what the caller
asked for, and the same fail-open the method narrowing set out to close.

Assisted-by: ClaudeCode:claude-fable-5
…nopenable children

Three defects the adversarial re-review reproduced:

BackgroundServer reported the requested port, so port=0 produced
http://host:0 while the socket was bound elsewhere -- clients following
the documented url reached an unrelated service.

Group key validation opened children through zarr's synchronous API
directly on the event loop, serializing every concurrent request behind
it (20 concurrent requests: 10.5s, now 1.1s) and outlasting the shutdown
timeout. Move it to a worker thread.

Child lookup caught only KeyError, so a corrupt metadata document or a
codec from an uninstalled plugin surfaced as 500 and made a whole
subtree unservable during ordinary operation.

Assisted-by: ClaudeCode:claude-fable-5
…al review

Hostile keys the store cannot express (embedded NUL, over-long names) now
answer 404 instead of surfacing ValueError/OSError as a 500.

PUT bodies are capped at DEFAULT_MAX_BODY_SIZE and answer 413 past it;
Store.set takes a whole Buffer, so a body cannot be streamed and one
request would otherwise size the server's memory.

206 responses carry Content-Range, and a range beyond the end or an
inverted one answers 416 rather than an empty 206.

A 0-d v2 array's sole chunk, stored under "0", is now servable: the key
decodes to a 1-tuple no 0-d grid could match, so it needs the array's
dimensionality to disambiguate.

Array and group metadata key sets are split, so a v2 group no longer
claims .zarray as its own; bind failures report the likely cause instead
of a bare timeout; and serve_* gain a background: bool overload.

The two out-of-bounds chunk tests planted no data, so they passed for the
wrong reason -- mutating the bounds check to return True left the suite
green. They now plant data at the out-of-grid key, and that mutation
fails 3 tests.

Adds the missing coverage for this branch's one core change, and widens
the workflow's path filter to src/zarr, since the package resolves zarr
from the repo root and a core change can break it.

Assisted-by: ClaudeCode:claude-fable-5
… the body cap while reading

The round-3 review found the previous round's error handling was too broad
and its body cap too narrow.

except (ValueError, OSError) around store.get and store.set caught the whole
errno family, so EACCES, EROFS and ENOSPC all answered 404. Under the v3 spec
an absent chunk is an uninitialized one and a reader is right to substitute
the array's fill value, so 404 asserts something about the store's contents:
an unreadable chunk answered that way has a correct client silently
materialize fill values over data that exists, and a failed write looks like
a pointless one rather than a failure. Only ENAMETOOLONG and EINVAL now mean
"this key names nothing"; everything else surfaces. NUL bytes are rejected in
the guard instead, so the store's own errors always mean real I/O trouble.

max_body_size only consulted Content-Length, which a chunked request does not
send, so request.body() buffered the whole thing before the check: 256 MiB
passed a 1 KiB cap at 572 MiB peak RSS. The body is now read incrementally
and abandoned at the cap -- the same attack peaks at 59 MiB.

Also: a range too wide to allocate answers 416 rather than raising
MemoryError; the force_exit fallback join is bounded, so shutdown cannot
outlast its timeout; DEFAULT_MAX_BODY_SIZE is importable, since it is the
documented default of three public functions; and _make_starlette_app loses a
parameter it never read.

Adds the coverage the review found missing: shard-grid bounds (the mutant
served an out-of-grid shard key against a green suite), wrong-arity chunk
keys, the chunked body path, I/O failures as 5xx, and a parser-level
assertion for inverted ranges, which the status code alone could not
distinguish on a MemoryStore.

The drive-letter check stays unconditional. It over-rejects a first-segment
node name like a:b, which is legal on POSIX -- but ntpath treats any single
character before a colon as a drive, so there is no safe subset, and the
guard is a string gate in front of an arbitrary Store whose path semantics
this package cannot know.

Assisted-by: ClaudeCode:claude-fable-5
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants