Skip to content
Open
Changes from all commits
Commits
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
113 changes: 112 additions & 1 deletion rust/cubestore/cubestore/src/http/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,12 @@ use http_auth_basic::Credentials;
use log::error;
use log::info;
use log::trace;
use log::Level;
use serde::Deserialize;
use std::collections::{BTreeMap, HashMap};
use std::convert::TryFrom;
use std::error::Error as StdError;
use std::io;
use std::net::SocketAddr;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, SystemTime};
Expand All @@ -43,6 +45,7 @@ use tokio::io::{AsyncWriteExt, BufReader};
use tokio::sync::mpsc::Sender;
use tokio::sync::{mpsc, Mutex};
use tokio_tungstenite::tungstenite;
use tokio_tungstenite::tungstenite::error::ProtocolError;
use tokio_util::sync::CancellationToken;
use warp::filters::ws::{Message, Ws};
use warp::http::StatusCode;
Expand Down Expand Up @@ -149,6 +152,71 @@ fn message_too_large_reason(
}
}

/// Level at which an error from the websocket read stream is reported.
///
/// A peer that disappears without a close handshake ends the connection the
/// same way a graceful client does: nothing on this side failed and there is
/// nothing for an operator to act on, while a fleet of clients going away at
/// once — a rolling restart of the API, say — produces one line per connection.
/// Reporting those as errors buries the transport failures that do need
/// attention, so they are separated here.
///
/// `warp` boxes the underlying `tungstenite` error, so it has to be recovered
/// through `source()`; an error that is not one is left at `Error`, since it is
/// not known to be benign.
Comment on lines +155 to +166

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.

This doc block is 11 lines for a function whose body is 8. The load-bearing facts are that a vanished peer isn't actionable and that warp boxes the tungstenite error so it must come back through source() — the rolling-restart narrative is already in the commit message. The source() sentence is also a verbatim repeat of the one on message_too_large_reason (line 115).

Something like:

/// Level at which an error from the websocket read stream is reported. A peer
/// that vanishes without a close handshake is not actionable, and a fleet of
/// them going away at once would otherwise bury real transport failures.
/// `warp` boxes the `tungstenite` error, so it comes back through `source()`;
/// anything else is not known to be benign and stays at `Error`.

Same for the four inline blocks inside tungstenite_error_level — each explains a one-line arm in 3–4 lines. The ResetWithoutClosingHandshake and ReceivedAfterClosing ones earn their place (they say why the level is not the obvious one); the _ => catch-all mostly re-lists what the arms above already exclude.

fn websocket_error_level(e: &warp::Error) -> Level {
match e
.source()
.and_then(|s| s.downcast_ref::<tungstenite::Error>())
{
Some(e) => tungstenite_error_level(e),
None => Level::Error,
}
}

fn tungstenite_error_level(e: &tungstenite::Error) -> Level {
match e {
// The socket reached end of file or was reset before a close frame
// arrived. Clients drop connections this way by design — `ws`'s
// `terminate()`, a killed process, a closed browser tab — and the
// server learns nothing else about them.
tungstenite::Error::Protocol(ProtocolError::ResetWithoutClosingHandshake) => Level::Debug,
tungstenite::Error::Io(io) => {
if is_peer_gone(io.kind()) {
Level::Debug
} else {
Level::Error
}
}
// A finished close handshake. The stream reports this as its end rather
// than as an error, so it is not expected here, but it is a normal
// close either way.
tungstenite::Error::ConnectionClosed | tungstenite::Error::AlreadyClosed => Level::Debug,
// The peer sent a frame after its own close frame. Harmless for this
// connection — whatever was already in flight raced the close — but a
// client that does it often is not closing correctly.
tungstenite::Error::Protocol(ProtocolError::ReceivedAfterClosing) => Level::Warn,
// Everything else is either a real transport failure (TLS, capacity,
// an IO error that is not a vanished peer) or a protocol violation the
// client should never commit, such as an invalid opcode, a masking
// violation or an oversized control frame.
_ => Level::Error,
}
}

/// Whether an IO error means the peer is simply gone, as opposed to the
/// connection failing while the peer is still there.
fn is_peer_gone(kind: io::ErrorKind) -> bool {
matches!(
kind,
io::ErrorKind::ConnectionReset
| io::ErrorKind::ConnectionAborted
| io::ErrorKind::BrokenPipe
| io::ErrorKind::NotConnected
| io::ErrorKind::UnexpectedEof
)
}

pub struct HttpServer {
bind_address: String,
sql_service: Arc<dyn SqlService>,
Expand Down Expand Up @@ -499,7 +567,10 @@ impl HttpServer {
error!("Websocket close send error: {:?}", e)
}
}
None => error!("Websocket error: {:?}", e),
None => log::log!(
websocket_error_level(&e),

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 read path is now classified, but the send paths in the same loop are not, and a vanished peer reliably produces an error there too. In the rolling-restart scenario the PR targets, a client that goes away with a query in flight hits web_socket.send(...) at line 541 (Websocket message send error) — warp returns Error(tungstenite::Error::ConnectionClosed) or Io(BrokenPipe) — before or instead of the read stream error, so the node still emits an error line per connection and the alerting threshold is still crossed.

Same applies to the two Websocket close send error sites (lines 530 and 567): sending a close frame to a peer that already left is exactly the benign case.

Suggest routing all three through the same classifier, e.g.

log::log!(websocket_error_level(&e), "Websocket message send error: {:?}", e);

The classification function itself is send/receive agnostic, so no change is needed to it. Fix this →

"Websocket error: {:?}", e
),
}
break;
}
Expand Down Expand Up @@ -1456,6 +1527,46 @@ mod tests {
use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream};
use url::Url;

#[test]
fn websocket_error_levels() {
// A peer gone without a close handshake, in either of the two shapes it
// reaches the read stream in.
assert_eq!(
tungstenite_error_level(&tungstenite::Error::Protocol(
ProtocolError::ResetWithoutClosingHandshake
)),
Level::Debug
);
assert_eq!(
tungstenite_error_level(&tungstenite::Error::Io(io::Error::from(
io::ErrorKind::ConnectionReset
))),
Level::Debug
);

// A close frame raced by an in-flight frame.
assert_eq!(
tungstenite_error_level(&tungstenite::Error::Protocol(
ProtocolError::ReceivedAfterClosing
)),
Level::Warn
);

// A protocol violation and an IO failure that is not a vanished peer.
assert_eq!(
tungstenite_error_level(&tungstenite::Error::Protocol(ProtocolError::InvalidOpcode(
7
))),
Level::Error
);
assert_eq!(
tungstenite_error_level(&tungstenite::Error::Io(io::Error::from(
io::ErrorKind::PermissionDenied
))),
Level::Error
);
}

/// Minimal SqlService that always replies with a fixed DataFrame, used to
/// drive process_command in unit tests.
struct StubService(Arc<DataFrame>);
Expand Down
Loading