Skip to content

feat!: improve error handling - #130

Merged
Frando merged 17 commits into
mainfrom
Frando/error-cases
Oct 9, 2026
Merged

Frando merged 17 commits into
mainfrom
Frando/error-cases

Conversation

@Frando

@Frando Frando commented Oct 2, 2026 •

Copy link
Copy Markdown
Member

This PR contains changes to error handling.

Currently, when the server receives a request that it cannot decode, it ends the connection unconditionally. handle_connection returns, and all other requests on the connection fail too. When a request is too large, the server closes the connection with code 1, and the client only sees "failed to read size".

Improve handling of bad requests

When the server cannot read a request, it now stops and resets the streams of that request. It uses code 1 if the request is too large, and the new code 3 (ERROR_CODE_DECODE_FAILED) if the request does not decode. The client of that request gets a clear error. What happens next depends on the server:

  • Handler::handle_connection closes the connection with the same code, as for any other protocol violation.
  • With Handler::skip_bad_requests(true), the handler reads the next request instead. This is for protocols that add request types over time.
  • read_request and read_request_raw return the new ReadRequestError, so a server with its own loop decides. After MaxMessageSizeExceeded and InvalidRequest, the connection is still open and the server can read on. They return Connection when the connection fails, also in the middle of a request. They still return Ok(None) when the connection closes cleanly, now also when this side closed it. ReadRequestError converts into io::Error and iroh's AcceptError, so existing ? calls keep working.

Improve handling of bad channel messages

A remote oneshot::Receiver or mpsc::Receiver now stops its stream with code 3 when a message does not decode, so the sender learns why. For mpsc, this ends the channel. Before, a receiver could skip such a message and read the next one. We must make this change before 1.0, because after 1.0 it would break code that skips such messages. An option to skip them can come later.

Better error types

  • Error, RequestError, WriteError, SendError, and both RecvErrors are #[non_exhaustive], so they can get new cases after 1.0.
  • Code 2 is renamed from ERROR_CODE_INVALID_POSTCARD to ERROR_CODE_ENCODE_FAILED, to match ERROR_CODE_DECODE_FAILED.

Breaking changes

  • changed: Error, RequestError, WriteError, SendError, mpsc::RecvError, and oneshot::RecvError are #[non_exhaustive]. Add a wildcard arm to matches.
  • changed: read_request and read_request_raw return Result<Option<_>, ReadRequestError>, not io::Result<Option<_>>.
  • changed: a server resets the streams of a bad request, then closes the connection with code 3 (does not decode) or code 1 (too large). Before, a request that does not decode dropped the connection with code 0.
  • changed: a remote mpsc::Receiver stops its stream with code 3 if a message does not decode. Further reads fail, so a receiver can no longer skip such a message and read the next one.
  • changed: rpc::ERROR_CODE_INVALID_POSTCARD is renamed to rpc::ERROR_CODE_ENCODE_FAILED.

@Frando
Frando marked this pull request as draft October 2, 2026 10:29
@Frando
Frando force-pushed the Frando/error-cases branch 4 times, most recently from d43c515 to acf91f4 Compare October 2, 2026 11:56
@Frando Frando changed the title feat!: bad requests fail per stream, and errors get classification methods feat!: improve error handling Oct 2, 2026
@Frando
Frando changed the base branch from main to Frando/feat-handler October 2, 2026 12:16
@Frando
Frando force-pushed the Frando/feat-handler branch from 4aeb9e5 to 8b65463 Compare October 2, 2026 12:26
@Frando
Frando force-pushed the Frando/error-cases branch from 659275f to 59fb956 Compare October 2, 2026 12:27
@Frando
Frando force-pushed the Frando/feat-handler branch from 8b65463 to 08fc52e Compare October 5, 2026 12:04
@Frando
Frando force-pushed the Frando/error-cases branch from 59fb956 to 785399e Compare October 5, 2026 12:07
@Frando
Frando force-pushed the Frando/feat-handler branch from 08fc52e to d570769 Compare October 5, 2026 12:20
@Frando
Frando force-pushed the Frando/error-cases branch 2 times, most recently from 4327b28 to 85bc9bb Compare October 5, 2026 13:38
@Frando
Frando marked this pull request as ready for review October 5, 2026 13:53
@Frando
Frando force-pushed the Frando/feat-handler branch from d570769 to da2f4a6 Compare October 5, 2026 13:56
@Frando
Frando force-pushed the Frando/error-cases branch 2 times, most recently from d372449 to de45a66 Compare October 6, 2026 08:51
…ustive

A request that the server cannot read currently ends the whole
connection without a reason. If the request does not decode,
`handle_connection` returns and drops the connection, so all requests on
it fail and the clients see a close with code 0. If it is too large, the
server closes the connection with code 1, and the client of the bad
request sees "failed to read size". The error enums also cannot get new
cases after 1.0 without a breaking change.

This commit makes the changes that must happen before 1.0. Error
classification methods and a way to skip bad requests can follow later
without a breaking change.

* Adds `#[non_exhaustive]` to `Error`, `RequestError`, `WriteError`,
  `SendError`, and both `RecvError`s.
* Stops and resets the streams of a bad request with the new code 3
  (`ERROR_CODE_DECODE_FAILED`), or with code 1 if the request is too
  large. `Handler::handle_connection` then closes the connection with
  the same code.
* Gives `read_request` and `read_request_raw` the error type
  `ReadRequestError` with the cases `MaxMessageSizeExceeded`,
  `InvalidRequest`, and `Connection`. For the first two cases the
  connection is still open, so a server with its own loop can read on.
  They still return `Ok(None)` for a clean close, now also for a close
  by this side. `ReadRequestError` converts into `io::Error` and iroh's
  `AcceptError`.
* Renames code 2 to `ERROR_CODE_ENCODE_FAILED`, to match
  `ERROR_CODE_DECODE_FAILED`.
* Reserves the codes 0 to 255 for irpc.

`RequestError::Unreachable` stays: `stack_error` cannot derive an empty
enum, and the variant exists only without the `rpc` feature.

* changed: `Error`, `RequestError`, `WriteError`, `SendError`, `mpsc::RecvError`, and `oneshot::RecvError` are `#[non_exhaustive]`. Add a wildcard arm to matches.
* changed: `read_request` and `read_request_raw` return `Result<Option<_>, ReadRequestError>`, not `io::Result<Option<_>>`.
* changed: a server closes the connection for a bad request with code 3 (does not decode) or code 1 (too large), after it resets the streams of that request. Before, a request that does not decode dropped the connection with code 0.
* changed: `rpc::ERROR_CODE_INVALID_POSTCARD` is renamed to `rpc::ERROR_CODE_ENCODE_FAILED`.
@Frando
Frando force-pushed the Frando/error-cases branch from de45a66 to 87df029 Compare October 6, 2026 10:56
@Frando
Frando changed the base branch from Frando/feat-handler to main October 6, 2026 10:56
Frando added 2 commits October 6, 2026 13:00
With versioned protocols, an unknown request type is a normal case, so a
server must be able to keep the connection.

`Handler::skip_bad_requests(true)` makes the handler read the next
request after a bad request, instead of closing the connection. It logs
the skipped request at `debug`.
A remote `oneshot::Receiver` or `mpsc::Receiver` currently returns an
`Io` error if a message does not decode, but does not tell the sender.
The sender sees the stream stop only when the receiver is dropped, with
code 0, and cannot tell why.

This makes both receivers stop the stream with `ERROR_CODE_DECODE_FAILED`,
the same code that a server uses for a request that does not decode. The
next send of the sender fails.

This must happen before 1.0: an `mpsc::Receiver` can currently skip a
message that does not decode and read the next one. After this change,
the channel ends. Making this change after 1.0 would break code that
skips such messages. An option to skip them can come later.

* changed: a remote `mpsc::Receiver` stops its stream with code 3 if a message does not decode. Further reads fail, so a receiver can no longer skip such a message and read the next one.
@Frando
Frando force-pushed the Frando/error-cases branch from 87df029 to 79db976 Compare October 6, 2026 11:01
@Frando
Frando requested a review from rklaehn October 6, 2026 11:52
@rklaehn

rklaehn commented Oct 7, 2026

Copy link
Copy Markdown
Collaborator

Looks good overall. One real issue:

Reset streams are classified as InvalidRequest. read_failed only maps ReadError::ConnectionLost to Connection; everything else (incl. Reset, ClosedStream, ZeroRttRejected) becomes InvalidRequest. So a client that cancels an rpc() mid-write (easy with a large request) gets its stream stopped with code 3 "decode failed", and the default handler closes the whole connection with code 3. Suggest handling Reset (and probably ClosedStream/ZeroRttRejected) separately: no code 3, no connection close, just read the next request.

Smaller things:

  • The default now actively close()s the connection, which kills in-flight responses in spawned handler tasks. Before, it just returned and the connection lingered while streams held refs. Fine as "protocol violation", but worth listing under breaking changes.
  • The client doesn't map code 3 to a distinct error. It sees a generic io error (Reset(3)), or ConnectionLost(ApplicationClosed(3)) if the close wins the race.
  • Minor inconsistency: LocallyClosed on accept_bi → Ok(None), but a local close mid-request → ReadRequestError::Connection.
  • The 0–255 code reservation is doc-only; HandlerError doesn't check it.
  • Maybe keep a #[deprecated] ERROR_CODE_INVALID_POSTCARD alias for the rename.
  • No test for the too-large path (code 1).

Frando added 4 commits October 7, 2026 16:31
When an irpc client drops the future of `rpc` in the middle of a write,
noq finishes the stream, so the server sees a truncated request. A
client can also reset the stream. The server treated both as a request
that does not decode: it reset the streams with code 3, and by default
closed the connection with code 3. So a single cancelled request ended
all other requests on the connection.

The client abandoned such a request. It did not send bad data. The
server now skips it and reads the next request. It resets its side of
the stream with code 0, because a drop would finish the stream, and a
client that still reads would take that for an empty response.

If the connection ended in the middle of a request, the next
`accept_bi` returns why. So a close with code 0 or a local close now
gives `Ok(None)` there too, as it does between requests.
The reservation was a note on one constant, said "must", and covered 0
to 255, but irpc does not check it and needs only a few codes. Describe
all irpc codes in one module section, and say what an application risks
if it uses a code below 16. The auth example used code 1, which irpc
uses for a message that is too large, so it now uses 400 and 401.
When the future of a request dropped in the middle of the write, the
stream of the request ended normally. So the server could not tell a
cancelled request from a broken one, and it skipped both. A
`RemoteSender` now resets its stream with the new code
`ERROR_CODE_ABORTED` if it drops before the request is written.

The server now skips a request only if its stream was reset. A stream
that ends before its request is complete is a request that does not
decode, so `skip_bad_requests` decides about it. The server skips a
reset with any code, not only `ERROR_CODE_ABORTED`. The update sender
of a request resets the stream with code 1 or 2 if an update fails, and
noq drops unread data on a reset, so the server can see that reset
before the request.
Frando added 2 commits October 8, 2026 11:10
`read_request_frame` returned `Ok(None)` both for a stream that the
client reset and for a lost connection, and matched all read errors
with a wildcard. So a lost connection logged a skipped request and
reset a stream on a connection that was gone. It now returns a private
`ReadFrame` enum that names each outcome, the two bad requests
included, so the caller handles all of them in one match. An
exhaustive match on `noq::ReadError` says why each variant lands where
it does.

The new test checks that a close in the middle of a request gives
`Ok(None)` from `read_request`, as a close between requests does.
`ReadRequestError::InvalidRequest` covered a request whose stream ended
early, a size prefix that is too long, and a request that postcard
cannot decode. Only the last one is the version mismatch that
`skip_bad_requests` exists for, and a caller had to downcast an
`io::Error` to get at its postcard error. It is now `DecodeFailed`
with the `postcard::Error` as its source. `InvalidRequest` keeps the
framing errors. Both still reset the streams with code 3.
@Frando

Frando commented Oct 8, 2026

Copy link
Copy Markdown
Member Author

Thanks. I pushed a few more commits.

  • The client now resets streams when the future is dropped before the request was sent. On the server, a stream that was reset before the request was read is now skipped instead of reported as an error.
  • I changed the docs that codes 0-15 are reserved, 0-255 seemed excessive. I'd still not enforce it, IMO it's enough to document this
  • I improved the structure of the error enums a bit and made the handling more consistent.

Please give it another review.

Frando and others added 2 commits October 8, 2026 11:44
`read_next` matched `Connection` first and then asked for the error
code with an `if let` that always matched. It now matches on
`error_code()` directly: no code means a connection error, and a code
means a bad request.
@rklaehn

rklaehn commented Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator

Small PRs on top of this: #133 #134 #135

The general theme of these is that the behaviour with local mpsc streams and remote noq or iroh streams should be identical for the cases where we can clearly determine that the connection is not at fault.

@rklaehn rklaehn 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.

My small PRs on top can be merged with this or later.

A few minor tweaks to the error refactor (#130):

- Proper docs formatting
- Also send correct error codes when using try_send
- Use an explicit function instead of a default when taking
`NoqSendState`
@Frando
Frando merged commit 3c360f2 into main Oct 9, 2026
19 of 20 checks passed
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