Skip to content

Commit d767481

Browse files
authored
feat(cli): clearer remote errors and a live status line (#117)
1 parent 8985861 commit d767481

4 files changed

Lines changed: 432 additions & 20 deletions

File tree

crates/skilld-command/src/remote.rs

Lines changed: 87 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2109,12 +2109,7 @@ fn problem_error(response: &HttpResponse) -> RemoteError {
21092109
instance: Option<String>,
21102110
}
21112111
serde_json::from_slice::<Problem>(&response.body).map_or_else(
2112-
|_| {
2113-
RemoteError::new(
2114-
"SERVICE_UNAVAILABLE",
2115-
format!("the remote service returned HTTP {}", response.status),
2116-
)
2117-
},
2112+
|_| service_unavailable_error(response),
21182113
|problem| {
21192114
let _ = (&problem.r#type, &problem.instance);
21202115
if problem.status != response.status {
@@ -2123,14 +2118,45 @@ fn problem_error(response: &HttpResponse) -> RemoteError {
21232118
"the remote problem status does not match HTTP",
21242119
);
21252120
}
2126-
RemoteError::new(
2127-
problem_code(&problem.code),
2128-
problem.detail.unwrap_or(problem.title),
2129-
)
2121+
let detail = match (response.status, retry_after_seconds(response)) {
2122+
(429, Some(seconds)) => {
2123+
let detail = problem.detail.unwrap_or(problem.title);
2124+
let detail = detail.trim_end_matches('.');
2125+
format!("{detail}. Retry in {seconds}s.")
2126+
}
2127+
_ => problem.detail.unwrap_or(problem.title),
2128+
};
2129+
RemoteError::new(problem_code(&problem.code), detail)
21302130
},
21312131
)
21322132
}
21332133

2134+
fn service_unavailable_error(response: &HttpResponse) -> RemoteError {
2135+
match (response.status, retry_after_seconds(response)) {
2136+
(429, Some(seconds)) => RemoteError::new(
2137+
"SERVICE_UNAVAILABLE",
2138+
format!("the remote service rate limited the request. Retry in {seconds}s."),
2139+
),
2140+
(status, _) if (500..600).contains(&status) => RemoteError::new(
2141+
"SERVICE_UNAVAILABLE",
2142+
format!(
2143+
"the remote service returned HTTP {status}. Retry in a minute. If it keeps failing, the service may be down."
2144+
),
2145+
),
2146+
(status, _) => RemoteError::new(
2147+
"SERVICE_UNAVAILABLE",
2148+
format!("the remote service returned HTTP {status}"),
2149+
),
2150+
}
2151+
}
2152+
2153+
fn retry_after_seconds(response: &HttpResponse) -> Option<u64> {
2154+
response
2155+
.header("retry-after")
2156+
.and_then(|value| value.parse::<u64>().ok())
2157+
.filter(|value| *value <= 3600)
2158+
}
2159+
21342160
fn problem_code(value: &str) -> &'static str {
21352161
match value {
21362162
"AUTH_REQUIRED" => "AUTH_REQUIRED",
@@ -2341,3 +2367,54 @@ struct GithubBlob {
23412367
encoding: String,
23422368
size: u64,
23432369
}
2370+
2371+
#[cfg(test)]
2372+
mod problem_tests {
2373+
use super::{HttpResponse, problem_error};
2374+
2375+
fn response(status: u16, headers: &[(&str, &str)], body: &str) -> HttpResponse {
2376+
HttpResponse {
2377+
status,
2378+
headers: headers
2379+
.iter()
2380+
.map(|(name, value)| ((*name).to_owned(), (*value).to_owned()))
2381+
.collect(),
2382+
body: body.as_bytes().to_vec(),
2383+
}
2384+
}
2385+
2386+
#[test]
2387+
fn server_errors_state_the_next_step() {
2388+
let error = problem_error(&response(500, &[], "not json"));
2389+
assert_eq!(error.code, "SERVICE_UNAVAILABLE");
2390+
assert_eq!(
2391+
error.message,
2392+
"the remote service returned HTTP 500. Retry in a minute. If it keeps failing, the service may be down."
2393+
);
2394+
}
2395+
2396+
#[test]
2397+
fn rate_limits_without_a_body_state_the_wait() {
2398+
let error = problem_error(&response(429, &[("retry-after", "7")], "not json"));
2399+
assert_eq!(error.code, "SERVICE_UNAVAILABLE");
2400+
assert_eq!(
2401+
error.message,
2402+
"the remote service rate limited the request. Retry in 7s."
2403+
);
2404+
}
2405+
2406+
#[test]
2407+
fn rate_limits_with_a_body_append_the_wait() {
2408+
let body = r#"{"code":"RATE_LIMITED","title":"Too many requests","status":429,"type":"about:blank"}"#;
2409+
let error = problem_error(&response(429, &[("retry-after", "12")], body));
2410+
assert_eq!(error.code, "RATE_LIMITED");
2411+
assert_eq!(error.message, "Too many requests. Retry in 12s.");
2412+
}
2413+
2414+
#[test]
2415+
fn client_errors_keep_the_plain_message() {
2416+
let error = problem_error(&response(404, &[], "not json"));
2417+
assert_eq!(error.code, "SERVICE_UNAVAILABLE");
2418+
assert_eq!(error.message, "the remote service returned HTTP 404");
2419+
}
2420+
}

crates/skilld-native/src/lib.rs

Lines changed: 86 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -115,12 +115,7 @@ impl HttpAdapter for NativeHttpAdapter {
115115
builder.send(request.body.as_slice())
116116
}
117117
}
118-
.map_err(|_| {
119-
RemoteError::new(
120-
"HTTP_TRANSPORT",
121-
"the remote request could not be completed",
122-
)
123-
})?;
118+
.map_err(|error| transport_error(&error, &request.url))?;
124119
let status = response.status().as_u16();
125120
let headers = response
126121
.headers()
@@ -153,8 +148,15 @@ impl HttpAdapter for NativeHttpAdapter {
153148
"the remote operation was cancelled",
154149
));
155150
}
156-
let read = reader.read(&mut buffer).map_err(|_| {
157-
RemoteError::new("HTTP_TRANSPORT", "the remote response could not be read")
151+
let read = reader.read(&mut buffer).map_err(|error| {
152+
let reason = match error.kind() {
153+
std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock => "timed out",
154+
_ => "could not be read",
155+
};
156+
RemoteError::new(
157+
"HTTP_TRANSPORT",
158+
format!("the remote response {reason}. Retry the command."),
159+
)
158160
})?;
159161
if read == 0 {
160162
break;
@@ -174,3 +176,79 @@ impl HttpAdapter for NativeHttpAdapter {
174176
})
175177
}
176178
}
179+
180+
fn transport_error(error: &ureq::Error, request_url: &str) -> RemoteError {
181+
let host = Url::parse(request_url)
182+
.ok()
183+
.and_then(|url| url.host_str().map(str::to_owned))
184+
.unwrap_or_else(|| "the remote service".to_owned());
185+
let reason = match error {
186+
ureq::Error::HostNotFound => format!("the {host} address could not be resolved"),
187+
ureq::Error::ConnectionFailed => format!("the connection to {host} failed"),
188+
ureq::Error::Timeout(_) => format!("the request to {host} timed out"),
189+
ureq::Error::Tls(_) | ureq::Error::Rustls(_) | ureq::Error::Pem(_) => {
190+
format!("the secure connection to {host} failed")
191+
}
192+
ureq::Error::Io(io) => match io.kind() {
193+
std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock => {
194+
format!("the request to {host} timed out")
195+
}
196+
std::io::ErrorKind::ConnectionRefused | std::io::ErrorKind::ConnectionReset => {
197+
format!("the connection to {host} failed")
198+
}
199+
std::io::ErrorKind::NotFound => format!("the {host} address could not be resolved"),
200+
_ => "the remote request could not be completed".to_owned(),
201+
},
202+
_ => "the remote request could not be completed".to_owned(),
203+
};
204+
let timed_out = matches!(error, ureq::Error::Timeout(_))
205+
|| matches!(error, ureq::Error::Io(io) if io.kind() == std::io::ErrorKind::TimedOut);
206+
let secure = matches!(
207+
error,
208+
ureq::Error::Tls(_) | ureq::Error::Rustls(_) | ureq::Error::Pem(_)
209+
);
210+
let recovery = if secure {
211+
"Check the system clock and certificates, then retry."
212+
} else if timed_out {
213+
"Retry the command. A slow network can cause this."
214+
} else {
215+
"Check the network connection, then retry the command."
216+
};
217+
RemoteError::new("HTTP_TRANSPORT", format!("{reason}. {recovery}"))
218+
}
219+
220+
#[cfg(test)]
221+
mod tests {
222+
use super::transport_error;
223+
224+
fn message(error: ureq::Error) -> String {
225+
transport_error(&error, "https://skilld.dev/api/v1/skills").message
226+
}
227+
228+
#[test]
229+
fn dns_failures_name_the_host_and_a_recovery_step() {
230+
assert_eq!(
231+
message(ureq::Error::HostNotFound),
232+
"the skilld.dev address could not be resolved. Check the network connection, then retry the command."
233+
);
234+
}
235+
236+
#[test]
237+
fn connection_failures_name_the_host() {
238+
assert_eq!(
239+
message(ureq::Error::ConnectionFailed),
240+
"the connection to skilld.dev failed. Check the network connection, then retry the command."
241+
);
242+
}
243+
244+
#[test]
245+
fn timeouts_say_so() {
246+
assert_eq!(
247+
message(ureq::Error::Io(std::io::Error::new(
248+
std::io::ErrorKind::TimedOut,
249+
"timed out"
250+
))),
251+
"the request to skilld.dev timed out. Retry the command. A slow network can cause this."
252+
);
253+
}
254+
}

crates/skilld-native/src/main.rs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
mod embedded_skill;
22
mod native_auth;
3+
mod status;
34

45
use std::env;
56
use std::io::{IsTerminal, Write};
@@ -22,6 +23,7 @@ use skilld_native::update_ui::{
2223
CommandInteractiveUpdateHost, require_interactive_tty, run_interactive_update,
2324
write_static_summary,
2425
};
26+
use status::StatusLine;
2527
use terminal_size::Width;
2628

2729
fn main() -> ExitCode {
@@ -97,7 +99,7 @@ fn main() -> ExitCode {
9799
}
98100

99101
let mut stdout = std::io::stdout().lock();
100-
let mut stderr = std::io::stderr().lock();
102+
let mut stderr = std::io::stderr();
101103
let output = OutputContext::auto(
102104
stdout.is_terminal(),
103105
active_agent_detected(),
@@ -106,7 +108,14 @@ fn main() -> ExitCode {
106108
env::var("TERM").is_ok_and(|term| term.eq_ignore_ascii_case("dumb")),
107109
terminal_width(),
108110
);
109-
let result = run_with_output(args, host.as_ref(), output, &mut stdout, &mut stderr);
111+
let label = status::status_label(args.iter().map(|arg| arg.to_string_lossy()));
112+
let status = match label {
113+
Some(label) => StatusLine::for_terminal(label, output),
114+
None => StatusLine::disabled(),
115+
};
116+
let mut gated = status::GatedStderr::new(&mut stderr, status);
117+
let result = run_with_output(args, host.as_ref(), output, &mut stdout, &mut gated);
118+
gated.finish_status();
110119
ExitCode::from(result.exit_code)
111120
}
112121

0 commit comments

Comments
 (0)