From 042cf430ff20ab9f50590b3008d2e7631f3d91dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iwo=20Strzebo=C5=84ski?= Date: Mon, 19 Oct 2026 00:37:27 +0000 Subject: [PATCH] Include the cause chain when go2rtc cannot be reached reqwest's Display is only "error sending request for url (...)" - the reason is one or two sources down, and "connection refused" versus "timed out" is the entire content of the message. This error is relayed to the cloud and then to a viewer, so it is the only explanation anyone gets for a camera that will not play. It now reads "could not reach go2rtc at http://127.0.0.1:1984: ...", naming go2rtc explicitly, because by far the likeliest cause is that it is not running and an error mentioning only a port sends whoever reads it looking at the network instead. --- src/uplink/webrtc.rs | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/src/uplink/webrtc.rs b/src/uplink/webrtc.rs index 396f891..583c082 100644 --- a/src/uplink/webrtc.rs +++ b/src/uplink/webrtc.rs @@ -62,6 +62,29 @@ fn checked_stream_name(name: &str) -> anyhow::Result<&str> { Ok(name) } +/// Flattens an error's `source` chain into one line. +/// +/// Needed because reqwest's own `Display` is just "error sending request for +/// url (...)" - the reason lives one or two sources down, and "connection +/// refused" versus "timed out" is the whole content of the message. This +/// error travels to the cloud and then to a viewer, so it is the only +/// explanation anyone gets. +fn with_causes(err: &(dyn std::error::Error + 'static)) -> String { + let mut out = err.to_string(); + let mut source = err.source(); + while let Some(cause) = source { + let text = cause.to_string(); + // Skip a cause that only repeats what we already said, which hyper's + // wrappers do. + if !out.contains(&text) { + out.push_str(": "); + out.push_str(&text); + } + source = cause.source(); + } + out +} + /// Posts the browser's offer to go2rtc and returns its answer SDP. pub async fn negotiate(api_listen: &str, camera_stream: &str, offer_sdp: &str) -> anyhow::Result { let base = local_api_base(api_listen)?; @@ -73,7 +96,13 @@ pub async fn negotiate(api_listen: &str, camera_stream: &str, offer_sdp: &str) - .body(offer_sdp.to_string()) .timeout(NEGOTIATION_TIMEOUT) .send() - .await?; + .await + .map_err(|err| { + // Names go2rtc explicitly. The most likely cause by far is that + // it is not running, and an error mentioning only a port sends + // whoever reads it looking at the network. + anyhow::anyhow!("could not reach go2rtc at {base}: {}", with_causes(&err)) + })?; // go2rtc answers 201 rather than 200 here, so this checks the class and // not a single code.