Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
ae8c383
feat(rpc, net): use glz::registry and http_router under -fno-exceptio…
stephenberry May 28, 2026
90dbed2
feat(net): translate reflected REST handler glz::expected errors to H…
stephenberry May 29, 2026
7b89ec5
feat(net): document REST handler glz::expected error responses in Ope…
stephenberry May 29, 2026
b5cb00c
feat(net): document 204 No Content for void REST endpoints in OpenAPI…
stephenberry May 29, 2026
08803de
docs(rpc): clarify try_on partial-registration side effects under -fn…
stephenberry May 29, 2026
a1896b7
test(net, rpc): cover the glz::error_ctx handler error branch (#2265)
stephenberry May 29, 2026
73cb4af
feat(net): add try_stream/try_websocket for return-value conflict rep…
stephenberry May 29, 2026
75dfbf3
docs(net): note the glz::expected handler-error behavior change (#2265)
stephenberry May 29, 2026
f855394
fix(rpc, net): address code-review feedback on glz::expected handling…
stephenberry May 29, 2026
3a6150f
test(net): cover REST expected data-member verbatim serialization (#2…
stephenberry May 29, 2026
28f4a16
docs(rpc): add handler-result vs stored-value writer seam design note…
stephenberry May 29, 2026
34a5b7d
Merge branch 'main' into feat/registry-no-exceptions-2265
stephenberry Jun 1, 2026
c21f255
feat(rpc): add glz::rpc::result alias and error factory helpers (#2265)
stephenberry Jun 1, 2026
464e627
fix(rpc): route JSON-RPC handler error detail through data, not message
stephenberry Jun 1, 2026
d77463b
docs(tests): scope no-exceptions claim to GCC/Clang; note MSVC keeps …
stephenberry Jun 2, 2026
bbaa84c
fix(registry): constrain on/try_on(merge) to non-REST protocols
stephenberry Jun 2, 2026
c544fbb
docs(repe): correct set_handler_error equivalence claim to code-only
stephenberry Jun 2, 2026
21068e3
Merge branch 'main' into feat/registry-no-exceptions-2265
stephenberry Jun 4, 2026
d9f7efe
Merge branch 'main' into feat/registry-no-exceptions-2265
stephenberry Jun 10, 2026
9fba211
Normalize degenerate success-coded errors in registry handlers
stephenberry Jun 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
249 changes: 249 additions & 0 deletions docs/design/rpc_handler_result_seam.md

Large diffs are not rendered by default.

61 changes: 61 additions & 0 deletions docs/exceptions.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,64 @@ std::string buffer{};
glz::ex::read_json(obj, buffer); // will throw on a read error
```

## Building without exceptions

Glaze compiles cleanly with `-fno-exceptions`. Core read/write functions never throw; they report problems through `glz::error_ctx`/`glz::expected`. The RPC `glz::registry` (REPE, JSON-RPC, and REST) and `glz::http_router` are also usable with `-fno-exceptions`. Errors are surfaced through return values rather than thrown or swallowed.

### Reporting route registration conflicts

`http_router::route()` throws `std::runtime_error` on a structural route conflict (a duplicate `:param`/`*wildcard` name at the same position, or a wildcard that is not the final segment) when exceptions are enabled. For exception-free builds, or whenever you want to handle a conflict explicitly, use the non-throwing variants:

```c++
glz::http_router router{};
if (auto ec = router.try_route(glz::http_method::GET, "/users/:id", handler); !ec) {
// ec.error() is a human-readable conflict message
}

glz::registry<glz::opts{}, glz::REST> registry{};
if (auto ec = registry.try_on(api); !ec) {
std::fprintf(stderr, "registration failed: %s\n", ec.error().c_str());
}
```

`try_route`/`try_on` return `glz::expected<void, std::string>` and never throw; `try_stream` and `try_websocket` give streaming and WebSocket routes the same return-value channel. The router also records the first conflict, queryable via `router.has_route_error()` / `router.route_error()`.

### Failing a request without throwing

A registered handler can fail a request by returning `glz::expected<T, E>`. The registry translates the error into the active protocol's error representation, so the same handler works with or without exceptions:

```c++
struct calculator {
// string error -> REPE error_code::parse_error / JSON-RPC internal error (-32603) / REST 500
std::function<glz::expected<int, std::string>(div_params)> divide = [](div_params p)
-> glz::expected<int, std::string> {
if (p.denominator == 0) return glz::unexpected(std::string("division by zero"));
return p.numerator / p.denominator;
};
};
```

An error state must carry a real error: a degenerate success-coded error (`glz::error_code::none`, `glz::rpc::error_e::no_error`, or a `glz::http_error` with a status below 400) is normalized to the protocol's server-error fallback (REPE `parse_error`, JSON-RPC `-32603`, HTTP 500) so a failure can never masquerade as a success on the wire.

For full JSON-RPC fidelity (custom code, message, and `data`), return `glz::expected<T, glz::rpc::error>` — or its alias `glz::rpc::result<T>`. The `glz::rpc::invalid_params`, `invalid_request`, `method_not_found`, `internal_error`, and `parse_error` helpers (plus `glz::rpc::fail(code, data)` for any other code, including the server-error range) build the `glz::unexpected<glz::rpc::error>` for you, with the optional argument carried in the `data` member. REPE handlers may return `glz::expected<T, glz::error_code>` to choose the response error code. When exceptions are enabled, a handler that throws is still caught and reported as before.

#### REST

For REST the error is translated into an HTTP response rather than an RPC error envelope: the success value is serialized as the response body, while an error sets a non-2xx status and serializes a `glz::http_error` body (`{"status":N,"message":...}`, which a client can read straight back into a `glz::http_error`). The status is derived from the error type:

- `glz::error_code` maps to a sensible HTTP status (`invalid_body`/`parse_error`/`invalid_query`/`syntax_error`/`constraint_violated` → 400, `method_not_found`/`key_not_found`/`nonexistent_json_ptr` → 404, `timeout` → 504, otherwise 500).
- A `std::string` or `glz::error_ctx` error maps to 500 (treated as a server-side failure).
- Return `glz::expected<T, glz::http_error>` to choose the status and message explicitly:

```c++
std::function<glz::expected<user, glz::http_error>(int)> find_user = [](int id)
-> glz::expected<user, glz::http_error> {
if (auto* u = lookup(id)) return *u;
return glz::unexpected(glz::http_error{404, "user not found"});
};
```

`glz::rpc::error` is JSON-RPC-specific and is **not** a REST error type, since `glaze/net` does not depend on the JSON-RPC extension; use `glz::http_error` for explicit status control. Raw `glz::http_router` handlers that take a `glz::response&` can of course call `res.status(...)` directly instead of returning `glz::expected`.

> **Behavior change:** Previously a reflected handler returning `glz::expected<T, E>` had its result serialized directly, so an error came back as a *successful* response whose body was `{"unexpected": <E>}` (a REPE/JSON-RPC result, or an HTTP 200 for REST). The registry now intercepts `glz::expected` as the success/error channel across all three protocols: a present value is still unwrapped and serialized exactly as before, but an error is translated into a protocol error (a REPE/JSON-RPC error response, or a non-2xx HTTP status as described above) instead of a success payload. Only the error path changed. The interception applies only to handler return values: a registered data member of type `glz::expected` is read as stored data and still serializes its `{"unexpected": ...}` JSON shape. A handler that intentionally wants the old payload can likewise return a non-`expected` type that serializes that shape rather than `glz::expected`.

14 changes: 14 additions & 0 deletions docs/networking/rest-registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,20 @@ private:
};
```

### Non-throwing registration

`registry.on(service)` throws `std::runtime_error` if registering a service produces a structural route conflict (and is unavailable under `-fno-exceptions`). Use `try_on` to detect registration failures by return value instead of throwing:

```cpp
glz::registry<glz::opts{}, glz::REST> registry{};
if (auto ec = registry.try_on(userService); !ec) {
std::fprintf(stderr, "route registration failed: %s\n", ec.error().c_str());
return;
}
```

`try_on` returns `glz::expected<void, std::string>` and never throws, so it works identically with and without exceptions. See [Glaze Exceptions](../exceptions.md) for the full no-exceptions story, including failing a request from a handler.

## Multiple Services

### Service Composition
Expand Down
8 changes: 5 additions & 3 deletions docs/rpc/json-rpc.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,14 +57,16 @@ int main() {
client;

// One long living callback per method for the server
server.on<"foo">([](foo_params const& params) -> glz::expected<foo_result, glz::rpc::error> {
server.on<"foo">([](foo_params const& params) -> glz::rpc::result<foo_result> {
// access to member variables for the request `foo`
// params.foo_a
// params.foo_b
if( params.foo_a != 0)
return foo_result{.foo_c = true, .foo_d = "new world"};
else
// Or return an error:
// Or return an error. For a standard code, glz::rpc::invalid_params("...") and
// friends are shorter; construct rpc::error directly to set a custom message or
// an implementation-defined server-error code as shown here:
return glz::unexpected{rpc::error{rpc::error_e::server_error_lower, {}, "my error"}};
});
server.on<"bar">([](bar_params const& params) {
Expand All @@ -76,7 +78,7 @@ int main() {
auto [request_str, inserted] = client.request<"foo">(
uuid,
foo_params{.foo_a = 1337, .foo_b = "hello world"},
[](glz::expected<foo_result, rpc::error> value, rpc::id_t id) -> void {
[](rpc::result<foo_result> value, rpc::id_t id) -> void {
// Access to value/error and/or id
});
// request_str: R"({"jsonrpc":"2.0","method":"foo","params":{"foo_a":1337,"foo_b":"hello world"},"id":"42"})"
Expand Down
22 changes: 22 additions & 0 deletions docs/rpc/jsonrpc-registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,28 @@ server.call(R"({"jsonrpc":"1.0","method":"greet","id":1})");
// Returns: {"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request",...},"id":1}
```

### Failing a request from a handler

A handler can fail a request by returning `glz::expected<T, E>`. The error is translated into a JSON-RPC error response, so you do not need to throw (and it works under `-fno-exceptions`):

```cpp
struct calculator {
std::function<glz::rpc::result<int>(div_params)> divide = [](div_params p) -> glz::rpc::result<int> {
if (p.denominator == 0) {
return glz::rpc::invalid_params("denominator must be non-zero");
}
return p.numerator / p.denominator;
};
};

server.call(R"({"jsonrpc":"2.0","method":"divide","params":{"numerator":10,"denominator":0},"id":1})");
// Returns: {"jsonrpc":"2.0","error":{"code":-32602,"message":"Invalid params","data":"denominator must be non-zero"},"id":1}
```

`glz::rpc::result<T>` is an alias for `glz::expected<T, glz::rpc::error>`, and the `glz::rpc::invalid_params`, `invalid_request`, `method_not_found`, `internal_error`, and `parse_error` helpers each return a `glz::unexpected<glz::rpc::error>` carrying that code; their optional argument becomes the JSON-RPC `data` member while `message` stays the standard text for the code. For any other code (including the implementation-defined server-error range) use `glz::rpc::fail(code, data)`, or construct a `glz::rpc::error` directly for full control over the code, message, and `data`.

Returning a string error type (for example `glz::expected<T, std::string>`) maps to `-32603` (Internal error) with the string as the message, which is convenient when the same handler is reused across protocols. When exceptions are enabled a handler that throws is still caught and reported as `-32603`.

## ID Types

JSON-RPC 2.0 supports string, integer, and null IDs. All are preserved in responses:
Expand Down
15 changes: 9 additions & 6 deletions examples/json-rpc.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ int main()
rpc::client<rpc::method<"foo", foo_params, foo_result>, rpc::method<"bar", bar_params, bar_result>> client;

// One long living callback per method for the server
server.on<"foo">([](const foo_params& params) -> glz::expected<foo_result, glz::rpc::error> {
server.on<"foo">([](const foo_params& params) -> glz::rpc::result<foo_result> {
// access to member variables for the request `foo`
// params.foo_a
// params.foo_b
Expand All @@ -46,18 +46,20 @@ int main()
}
else {
std::cout << "Server received invalid data:" << params.foo_b << std::endl;
// Or return an error:
return glz::unexpected{glz::rpc::error{glz::rpc::error_e::invalid_params, {}, "foo_a should be equal to 0"}};
// Or return an error. The invalid_params() helper carries the detail in the
// JSON-RPC `data` member; `message` stays the standard text for the code.
return glz::rpc::invalid_params("foo_a should be equal to 0");
}
});
server.on<"bar">([](const bar_params& params) { return bar_result{.bar_c = true, .bar_d = "new world"}; });

std::string uuid{"42"};
auto client_cb = [](glz::expected<foo_result, rpc::error> value, rpc::id_t id) -> void {
auto client_cb = [](rpc::result<foo_result> value, rpc::id_t id) -> void {
if (value)
std::cout << "Client received " << value.value().foo_c << ":" << value.value().foo_d << std::endl;
else
std::cerr << "Client received error " << value.error().message << std::endl;
std::cerr << "Client received error " << value.error().message
<< (value.error().data ? " (" + *value.error().data + ")" : "") << std::endl;
};

// One callback per client request
Expand Down Expand Up @@ -98,7 +100,8 @@ int main()

response = server.call(request_str);
std::cout << "Server json response :" << response << std::endl;
assert(response == R"({"jsonrpc":"2.0","error":{"code":-32602,"message":"foo_a should be equal to 0"},"id":"42"})");
assert(response ==
R"({"jsonrpc":"2.0","error":{"code":-32602,"message":"Invalid params","data":"foo_a should be equal to 0"},"id":"42"})");
err = client.call(response);
std::cout << "Client call with error result :" << err.message << std::endl;
}
38 changes: 38 additions & 0 deletions include/glaze/ext/jsonrpc.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,44 @@ namespace glz::rpc
};
};

// Convenience alias for a handler that fails a request with a full-fidelity JSON-RPC
// error. `glz::rpc::result<int>` is just `glz::expected<int, glz::rpc::error>`, so it
// does not have to be spelled twice (once for the stored handler signature and once
// for a lambda's trailing return type).
template <class T>
using result = glz::expected<T, error>;

// Build a `glz::unexpected<error>` so a handler can `return` it directly from a
// `result<T>` (i.e. `expected<T, rpc::error>`). `data` populates the JSON-RPC `data`
// member (detailed information); `message` stays the standard text for `code`. These
// remove the `glz::unexpected(glz::rpc::error{glz::rpc::error_e::..., ...})` boilerplate
// at handler error sites.
inline glz::unexpected<error> fail(error_e code, std::optional<std::string> data = std::nullopt)
{
return glz::unexpected(error{code, std::move(data)});
}

inline glz::unexpected<error> parse_error(std::optional<std::string> data = std::nullopt)
{
return fail(error_e::parse_error, std::move(data));
}
inline glz::unexpected<error> invalid_request(std::optional<std::string> data = std::nullopt)
{
return fail(error_e::invalid_request, std::move(data));
}
inline glz::unexpected<error> method_not_found(std::optional<std::string> data = std::nullopt)
{
return fail(error_e::method_not_found, std::move(data));
}
inline glz::unexpected<error> invalid_params(std::optional<std::string> data = std::nullopt)
{
return fail(error_e::invalid_params, std::move(data));
}
inline glz::unexpected<error> internal_error(std::optional<std::string> data = std::nullopt)
{
return fail(error_e::internal, std::move(data));
}

template <class Params>
struct request_t
{
Expand Down
Loading
Loading