Skip to content

refactor(api): serve espresso-node API via axum (rm tide 1/5)#4703

Open
sveitser wants to merge 14 commits into
mainfrom
ma/axum-server-core
Open

refactor(api): serve espresso-node API via axum (rm tide 1/5)#4703
sveitser wants to merge 14 commits into
mainfrom
ma/axum-server-core

Conversation

@sveitser

Copy link
Copy Markdown
Collaborator

Re-slice of #4592 (reference branch ss/remove-tide).

  • espresso-node serves its query API via the in-repo axum server in
    all serve modes; the tide App is still built but no longer binds
  • tide wire parity: /v0 and unversioned path rewrites to /v1,
    reward-state and ns-proof endpoints, tide 404/400 reward statuses,
    accept-negotiated JSON/vbs healthchecks, ws Close frames
  • no v0 legacy availability formats (consumer survey found no users)
  • demo configs and scripts default to /v1 paths
  • proof: unmodified surf-disco clients and test suites pass

Review focus: axum.rs error envelope and healthcheck wire shapes;
reward-status classification in api/state.rs; local single-variant
HealthStatus (moves to http-client in 4/5).

@gemini-code-assist

Copy link
Copy Markdown
Contributor
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Switch espresso-node's serve modes (full, fs, status-only, bare) to the
in-repo axum server. The tide-disco App is still constructed so the
module definitions keep compiling; it no longer binds the API port.

Restores tide-disco wire parity on the axum server: tide-compatible
paths and /v0 URI rewrites (without the unconsumed v0 legacy
availability formats), reward-state and namespace proof endpoints,
tide 404/400 statuses on reward endpoints, accept-negotiated JSON/vbs
healthcheck bodies (AppHealth shape at top level, bare HealthStatus
per module), and ws Close frames on stream completion.
@sveitser
sveitser force-pushed the ma/axum-server-core branch from 41fb01c to 041b34a Compare July 17, 2026 00:21
The separate axum_port option was removed when axum took over the main
ESPRESSO_NODE_API_PORT; these .env entries have no consumer.
@sveitser
sveitser marked this pull request as ready for review July 17, 2026 05:34
@claude

claude Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Claude finished @sveitser's task in 5m 56s —— View job


Code Review

I reviewed this against origin/main, cross-checking the wire-parity claims against the tide implementation that's being replaced. This is a large but carefully-scoped migration; the extensive parity comments made it easy to follow. Overall it looks solid and I have no blocking concerns — details below.

  • Read repo context & existing comments
  • Review axum.rs error envelope & healthcheck wire shapes
  • Review reward-status classification in api/state.rs
  • Review routing / legacy URI rewrites
  • Review lib.rs serve modes, node/api/options.rs, light-client
  • Review tests & config/scripts

What I verified

Reward-status classification (node/src/api/state.rs) — matches tide. I compared the new not_found/bad_request classifications against the tide handler in node/src/api/endpoints.rs, and they line up:

  • limit > 10000 → 400 (endpoints.rs:203-204) ✅
  • load_tree failure → 404 (endpoints.rs:217) ✅
  • bincode::deserialize failure → 404 (endpoints.rs:230) ✅ — semantically this is server-side corruption that "should never happen" (per tide's own comment), so 500 would arguably be more correct, but 404 is faithful to tide, which is the right call for this PR.
  • offset out of bounds → 404 (endpoints.rs:239-240) ✅

classify_query_error (NotFound/Missing → 404, else 500) and lc_error (status-preserving 400/404, else 500) are both reasonable and match tide's semantics for the merklized-state and light-client paths.

Error envelope (axum.rs:37-75). The {"Custom": {message, status}} shape with message-before-status field order correctly mirrors node::Error::Custom's declaration order for both JSON and vbs. The scoping caveat in the doc comment (specific-variant endpoints like availability::Error::FetchLeaf emit a different shape on tide and aren't matched) is accurately documented.

Healthcheck wire shapes (axum.rs:327-366). HealthStatus/AppHealth are byte-compatible with tide_disco's types (first-variant ordinal, snake_case, status-then-modules order), and both healthcheck endpoints are Accept-negotiated via encode_response. The single-variant HealthStatus and the "any {module} reports healthy" divergence are both documented.

Legacy URI rewrite (axum.rs:169-201, wired via MapRequestLayer in lib.rs:274). Running the rewrite before routing (rather than as Router::layer, which runs after) is the correct fix, and it's covered by a focused unit-test suite (/v0/v1, unversioned prefixing, reserved-path exclusion, query preservation, version-prefix boundaries). Docs routes are all /v2/-prefixed so they don't collide with the rewrite.

WS close handshake (axum.rs:278-281) and the parse_namespaces_str extraction (correctly re-classified as bad_request on the axum path) both look right.

Minor observations (non-blocking)

  1. /version is not content-negotiated (axum.rs:360 returns Json<Value> unconditionally), whereas /healthcheck is negotiated. If any binary (Accept: application/octet-stream) client deserializes the /version body as vbs rather than just checking the 200 status, this would diverge from tide. Worth a quick confirmation that surf_disco::Client::connect and the other pollers only check status here.

  2. create_combined_router / create_router_v1 remain pub and call with_top_level_routes internally (axum.rs:314), while serve_router also calls with_top_level_routes (lib.rs:260). No current caller combines them, but routing them together would panic axum on duplicate /, /healthcheck, /version routes. Since the live serve paths build routers manually and don't use create_combined_router, consider a doc note that it's a standalone helper (or that it already includes top-level routes) to avoid a future footgun.

  3. Status-only / bare modes build the tide app then drop(app) (options.rs) purely to keep the tide module definitions compiling. This is documented with a TODO and is expected for a 1/5 slice, just noting the wasted construction is intentional.

Nice work on the parity comments throughout — they make this migration reviewable, and I couldn't find a wire-shape regression.

@sveitser

Copy link
Copy Markdown
Collaborator Author

Problems found so far

  1. Going to base url redirects to /v2 which is WIP
  2. The /v1 API has no docs at all now.

sveitser added 7 commits July 17, 2026 14:53
Convert the 15 v1 module routers to aide's ApiRouter and annotate all
171 routes with summaries and descriptions lifted from the legacy
tide-disco toml specs, restoring the docs that died with the tide App.
Serve the spec at /v1/docs/openapi.json with Swagger UI at /v1 and
Scalar at /v1/scalar. Point / at /v1 (temporary redirect) while the
v2 API is a work in progress.

Wire-neutral: ApiJson delegates to axum::Json, so response bytes,
statuses, and headers are unchanged; the OpenAPI spec reports untyped
200 responses instead of adding JsonSchema derives to domain types.
The docs routes and OpenAPI finish lived only in create_router_v1,
which no serve mode calls: the serve modes compose module routers
directly, so the running node 404'd on /v1 while the unit tests
passed. Extract finish_v1_docs() and route all four serve modes
(full, fs, status, bare) through it; the generated spec now reflects
exactly the modules each mode mounts. Regression test assembles the
router the way serve_axum_status does, wrapped in the production
top-level-routes and legacy-URI-rewrite layers.
aide only derives path parameters from named-struct Path<T>
extractors; the v1 handlers all use primitives and tuples, so every
operation showed zero parameters and Swagger's try-it-out could not
fill the URL templates. Declare a required string parameter for every
{name} template segment after finish_api (string because primitive
extractors carry no type metadata; handlers parse).

Also: Swagger UI opens operations with try-it-out enabled; the
test_api example moves to port 5001 (macOS AirPlay holds 5000) and
its startup log lists the v1 doc URLs.
Numeric template segments (height, from/until, epoch, view, index,
limit, offset, namespace, finalized, ...) are declared as unsigned
integers, matching each handler's Path<T> extractor; hash, key, and
address segments stay strings. Unknown future names default to string.
Documentation-only: handlers parse the raw segment regardless.
The first path segment after /v1/ becomes the operation's OpenAPI tag,
so Swagger renders one collapsible section per module (availability,
status, catchup, ...), sorted.
Palette from the staking UI: cream background, coffee headings,
cookie-brown GET and orange POST accents; topbar hidden.
Comment thread crates/espresso/api/src/v1/database.rs
/// `namespaces` is the raw `TaggedBase64`-encoded path segment produced by the light-client
/// client (tag `NS`, wrapping a JSON `Vec<u64>`); decoding it is left to the implementation
/// so this crate does not need a `tagged-base64` dependency.
async fn get_lc_namespaces_proof_range(

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.

why is this added in this PR?

Comment thread crates/espresso/node/src/api/state.rs Outdated
>;

async fn get_reward_state_height(&self) -> anyhow::Result<u64> {
use hotshot_query_service::merklized_state::MerklizedStateHeightPersistence;

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.

nit: can we remove this in function import

Comment thread crates/espresso/node/src/api/state.rs Outdated
}

async fn get_reward_state_v2_height(&self) -> anyhow::Result<u64> {
// Both the V1 and V2 reward merklized-state modules share the same underlying

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.

actually this comment can be made better by mentioning that the last_merklized_state_height is the same for all the state modules

Comment thread crates/espresso/node/src/api/state.rs Outdated
snapshot: espresso_api::v1::Snapshot,
key: String,
) -> anyhow::Result<Self::RewardStatePathV1> {
use hotshot_query_service::merklized_state::{

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.

same here

Comment thread crates/espresso/node/src/api/state.rs Outdated
HsSnapshot::Commit(commit)
},
};
let key: espresso_types::v0_3::RewardAccountV1 = key

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.

also it would make reading code easier if we can remove the full import from some places

Comment thread crates/espresso/api/src/lib.rs Outdated
let mut router = axum::with_top_level_routes(router);
if let Some(limit) = max_connections {
router = router.layer(
tower::ServiceBuilder::new()

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.

does this limit also apply to streaming endpoints?

Comment thread crates/espresso/api/src/lib.rs Outdated
let mut router = axum::with_top_level_routes(router);
if let Some(limit) = max_connections {
router = router.layer(
tower::ServiceBuilder::new()

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.

does this limit also apply to streaming endpoints?

.with_state(state)
}

pub(crate) fn router_availability<S>(state: S) -> ApiRouter

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.

hmmm this file is too big. I am wondering if these routes registration should be moved to where we define the traits or maybe into their own modules. So like the structure would be

 axum/mod.rs
 axum/availability.rs, axum/node.rs ...
 axum/tests.rs

"/v1/reward-state-v2/reward-amounts/{height}/{offset}/{limit}";
pub const REWARD_MERKLE_TREE_V2_ROUTE: &str =
"/v1/reward-state-v2/reward-merkle-tree-v2/{height}";
pub const REWARD_STATE_HEIGHT_ROUTE: &str = "/v1/reward-state/block-height";

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.

The version is hardcoded into each route path. Previously we could simply bump the version, and all the routes would automatically use the new version. How would this work with axum now?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

We could use this? https://docs.rs/axum/latest/axum/struct.Router.html#method.nest

But I don't really want to rewrite even more in this PR.

sveitser added 5 commits July 17, 2026 18:17
Review feedback: 44 in-function use statements move to the file
header; long v0_3/v0_4 turbofish paths shortened via imports; the
shared last_merklized_state_height comment now names all state
modules.
Review feedback: the macro now stringifies the parameter ident instead
of taking a redundant string. Also fixes namespace_proof_by_payload_hash,
whose "payload-hash" literal never matched the {payload_hash}
placeholder, leaving it unsubstituted in built paths.
Review findings. tower's concurrency permit is released at the 101
upgrade, so websockets escaped ESPRESSO_NODE_API_MAX_CONNECTIONS
entirely; a shared semaphore now gives plain requests a slot while in
flight and streaming sockets one for their lifetime, with a real-TCP
regression test.

The API port also binds before router composition: OpenAPI generation
takes ~0.5s in debug builds, and clients connecting during it now
queue in the accept backlog instead of being refused. This is what
broke the test-postgres shards: first-request test helpers panic on a
refused connection.
drive_ws_stream only noticed a dead client on the next send, so a
socket whose stream was quiet held its max_connections slot and its
stream task forever; reconnect churn exhausted the demo's budget of 25
and every plain request got 429, killing the native-demo e2e tests
(claim-rewards-loop exits on non-404 and tears the demo down). Poll
the client side with select so disconnects release the slot and end
the task immediately. Regression test now uses a never-yielding
stream.
The demo's nasty-client holds up to 100 open streams per resource type
by design, against a request budget of 25; counting sockets for their
lifetime starved every plain request on node-1 with 429 and killed the
native-demo e2e tests. max_connections goes back to bounding in-flight
requests only (as in every green run); a socket's slot is released at
the 101 upgrade. Stream tasks still end promptly on client disconnect,
which was the actual leak.
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.

2 participants