1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
use axum::http::StatusCode;
use axum::response::{Html, IntoResponse, Response};
use freenet_stdlib::client_api::{ErrorKind, RequestError};
use freenet_stdlib::prelude::ContractInstanceId;
use std::fmt::{Display, Formatter};
#[derive(Debug)]
pub(super) enum WebSocketApiError {
/// Something went wrong when calling the user repo.
InvalidParam {
error_cause: String,
},
NodeError {
error_cause: String,
},
AxumError {
error: ErrorKind,
},
MissingContract {
instance_id: ContractInstanceId,
},
}
impl WebSocketApiError {
pub fn status_code(&self) -> StatusCode {
match self {
WebSocketApiError::InvalidParam { .. } => StatusCode::BAD_REQUEST,
WebSocketApiError::NodeError { .. } => StatusCode::INTERNAL_SERVER_ERROR,
WebSocketApiError::AxumError { .. } => StatusCode::INTERNAL_SERVER_ERROR,
WebSocketApiError::MissingContract { .. } => StatusCode::NOT_FOUND,
}
}
pub fn error_message(&self) -> String {
match self {
WebSocketApiError::InvalidParam { error_cause } => {
format!("Invalid request params: {error_cause}")
}
WebSocketApiError::NodeError { error_cause } => format!("Node error: {error_cause}"),
WebSocketApiError::AxumError { error } => format!("Server error: {error}"),
WebSocketApiError::MissingContract { instance_id } => {
format!("Missing contract {}", instance_id.encode())
}
}
}
}
impl Display for WebSocketApiError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.error_message())
}
}
impl From<WebSocketApiError> for Response {
fn from(error: WebSocketApiError) -> Self {
let body = Html(error.error_message());
(error.status_code(), body).into_response()
}
}
impl IntoResponse for WebSocketApiError {
fn into_response(self) -> Response {
// Check for errors that indicate the peer is still connecting to the network
if let WebSocketApiError::AxumError { ref error } = self {
if matches!(error, ErrorKind::EmptyRing | ErrorKind::PeerNotJoined) {
return (StatusCode::SERVICE_UNAVAILABLE, Html(connecting_page())).into_response();
}
}
// Transient errors during contract fetch: the node has peers but the
// GET hasn't completed yet (fresh node, sparse ring, etc.). Show a
// retry page that auto-refreshes the SAME URL so the user doesn't
// have to manually reload (#3472).
//
// Note: `ErrorKind` is `#[non_exhaustive]` so new stdlib variants
// will NOT match here. Each new release must explicitly decide
// whether the new variant is transient. The wildcard `_` at the
// bottom of the `match` falls through to 500.
// NOTE: `ErrorKind::OperationError` is deliberately NOT listed here.
// It is dual-use: the web GET path synthesizes it for transient
// timeouts AND the node emits it for terminal failures (e.g. a locally
// banned contract, exhausted GET). Treating it as transient would
// serve an infinite auto-refresh page for those terminal errors. The
// transient GET cases now use RequestError(Timeout) / ChannelClosed
// (see handle_get_response in path_handlers.rs), which are caught below.
let is_transient = matches!(
&self,
WebSocketApiError::AxumError {
error:
// Dead in current core (no raisers), but stdlib can emit
// it; defensive include so a stdlib bump doesn't silently
// lose retry behaviour.
ErrorKind::FailedOperation
// Node-recovery races: channel teardown, cold-start.
| ErrorKind::ChannelClosed
| ErrorKind::TransportProtocolDisconnect
| ErrorKind::NodeUnavailable
// The 30s GET fetch wrapper elapsed — the canonical #3472
// transient case. Synthesized by handle_get_response.
| ErrorKind::RequestError(RequestError::Timeout)
}
);
let (status, error_message) = if is_transient {
// Log the cause so operators can distinguish a fast op error
// from a slow-loading contract without changing the user-facing
// retry page.
if let WebSocketApiError::AxumError { error } = &self {
tracing::info!(%error, "serving retry page for transient contract-fetch error");
}
(StatusCode::SERVICE_UNAVAILABLE, retry_loading_page())
} else {
match self {
WebSocketApiError::InvalidParam { error_cause } => {
(StatusCode::BAD_REQUEST, error_cause)
}
WebSocketApiError::NodeError { error_cause }
if error_cause.starts_with("Contract not found") =>
{
(StatusCode::NOT_FOUND, error_cause)
}
WebSocketApiError::NodeError { error_cause } => {
(StatusCode::INTERNAL_SERVER_ERROR, error_cause)
}
err @ WebSocketApiError::MissingContract { .. } => {
(StatusCode::NOT_FOUND, err.error_message())
}
WebSocketApiError::AxumError { error } => {
// Already handled transient cases above; remaining
// AxumErrors are infrastructure failures.
(StatusCode::INTERNAL_SERVER_ERROR, format!("{error}"))
}
}
};
let body = Html(error_message);
// Prevent intermediaries/service-workers from pinning a stale retry
// page, and signal the client when it may retry.
let mut response = (status, body).into_response();
if is_transient {
response.headers_mut().insert(
axum::http::header::CACHE_CONTROL,
axum::http::HeaderValue::from_static("no-store"),
);
response.headers_mut().insert(
axum::http::header::RETRY_AFTER,
axum::http::HeaderValue::from_str(&RETRY_REFRESH_SECS.to_string())
.unwrap_or(axum::http::HeaderValue::from_static("60")),
);
}
response
}
}
/// Returns a short HTML page that redirects to the dashboard while the peer connects.
///
/// The homepage at `/` already shows full connection diagnostics, so rather than
/// duplicating that rendering here we redirect with a meta-refresh. The 503 status
/// code (set by the caller) tells programmatic clients the node is not yet ready.
fn connecting_page() -> String {
include_str!("errors/assets/connecting.html").to_string()
}
/// How often the retry page reloads (seconds). Long enough for a
/// cold GET to resolve on a sparse ring, short enough that the user
/// doesn't assume the page is dead.
const RETRY_REFRESH_SECS: u64 = 60;
/// Returns an HTML page that auto-refreshes the current URL every
/// [`RETRY_REFRESH_SECS`] seconds.
///
/// Used when a contract-fetch operation timed out — the node has peers and
/// is making progress, but the specific GET hasn't resolved yet. Rather
/// than showing a dead error, the page reloads itself. Once the contract
/// is cached, `contract_home` serves normally and the refresh loop stops
/// (the success response carries no `<meta http-equiv="refresh">`).
fn retry_loading_page() -> String {
format!(
r##"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="refresh" content="{refresh}">
<title>Loading contract…</title>
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex; justify-content: center; align-items: center; min-height: 100vh;
margin: 0; background: #0c0d0f; color: #edeeef; }}
.container {{ text-align: center; padding: 2rem; }}
h1 {{ font-size: 1.2rem; font-weight: 500; margin-bottom: 0.5rem; }}
p {{ color: #94969a; font-size: 0.85rem; margin-bottom: 0.3rem; }}
</style>
</head>
<body>
<div class="container">
<h1>Fetching contract from the network…</h1>
<p>This page will reload automatically.</p>
<p style="font-size:0.7rem;color:#585a5e">If this persists, check the <a href="/" style="color:#0abab5">dashboard</a>.</p>
</div>
</body>
</html>"##,
refresh = RETRY_REFRESH_SECS,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_ring_returns_service_unavailable() {
let err = WebSocketApiError::AxumError {
error: ErrorKind::EmptyRing,
};
let response = err.into_response();
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[test]
fn peer_not_joined_returns_service_unavailable() {
let err = WebSocketApiError::AxumError {
error: ErrorKind::PeerNotJoined,
};
let response = err.into_response();
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[tokio::test]
async fn failed_operation_returns_retry_page() {
let err = WebSocketApiError::AxumError {
error: ErrorKind::FailedOperation,
};
let response = err.into_response();
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(
response
.headers()
.get(axum::http::header::CACHE_CONTROL)
.map(|v| v.as_bytes()),
Some(&b"no-store"[..]),
);
assert!(
response
.headers()
.get(axum::http::header::RETRY_AFTER)
.is_some(),
);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let text = String::from_utf8_lossy(&body);
assert!(
text.contains(r#"<meta http-equiv="refresh" content="60"#),
"retry page must contain meta-refresh tag"
);
// The CSS must use single braces — a `format!` brace-escaping bug
// ({{{{ instead of {{) would emit `body {{ … }}`, invalid CSS that
// strips the page's styling.
assert!(
text.contains("body {") && !text.contains("body {{"),
"retry page CSS must emit single braces, got: {text}"
);
}
#[test]
fn operation_error_returns_internal_server_error() {
// OperationError is dual-use: the node emits it for TERMINAL failures
// (banned contract, exhausted GET) as well as transient ones, so it
// must NOT be classified transient — otherwise a banned contract would
// infinite-reload instead of showing an error. The transient GET cases
// use RequestError(Timeout)/ChannelClosed instead (#3472).
let err = WebSocketApiError::AxumError {
error: ErrorKind::OperationError {
cause: "contract banned".into(),
},
};
let response = err.into_response();
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
// And it must NOT carry the retry-page caching headers.
assert!(
response
.headers()
.get(axum::http::header::RETRY_AFTER)
.is_none(),
);
}
#[test]
fn channel_closed_returns_retry_page() {
let err = WebSocketApiError::AxumError {
error: ErrorKind::ChannelClosed,
};
let response = err.into_response();
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[test]
fn transport_disconnect_returns_retry_page() {
let err = WebSocketApiError::AxumError {
error: ErrorKind::TransportProtocolDisconnect,
};
let response = err.into_response();
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[test]
fn node_unavailable_returns_retry_page() {
let err = WebSocketApiError::AxumError {
error: ErrorKind::NodeUnavailable,
};
let response = err.into_response();
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[test]
fn request_error_timeout_returns_retry_page() {
// A RequestError(Timeout) is the same class of transient GET-fetch
// failure as OperationError("…timed out…") and must serve the 503
// auto-refresh page rather than a dead error (#3472).
let err = WebSocketApiError::AxumError {
error: ErrorKind::RequestError(RequestError::Timeout),
};
let response = err.into_response();
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[test]
fn other_axum_error_returns_internal_server_error() {
let err = WebSocketApiError::AxumError {
error: ErrorKind::Unhandled {
cause: "something broke".into(),
},
};
let response = err.into_response();
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
}