Skip to content
Merged
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
52 changes: 50 additions & 2 deletions crates/twirp/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -258,10 +258,22 @@ impl From<serde_json::Error> for TwirpErrorResponse {
}
}

// unable to build the request
// Map reqwest errors to semantically appropriate Twirp error codes.
// Transport failures (connect, timeout) → Unavailable (retryable).
// Request-building errors → InvalidArgument (caller's fault).
// Response-parsing errors → Internal (unexpected server behavior).
impl From<reqwest::Error> for TwirpErrorResponse {
fn from(e: reqwest::Error) -> Self {
invalid_argument(e.to_string()).with_rust_error(e)
let msg = e.to_string();
let resp = if e.is_builder() {
invalid_argument(msg)
} else if e.is_redirect() || e.is_body() || e.is_decode() {
internal(msg)
} else {
// connect, timeout, request, and anything else — treat as transient
unavailable(msg)
};
Comment thread
tclem marked this conversation as resolved.
resp.with_rust_error(e)
}
}

Expand Down Expand Up @@ -393,6 +405,42 @@ mod test {
assert_eq!(response, result);
}

#[tokio::test]
async fn reqwest_timeout_error_maps_to_unavailable() {
// Bind a listener that accepts but never responds, guaranteeing a timeout.
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let _accept_thread = std::thread::spawn(move || {
let (_stream, _) = listener.accept().unwrap();
std::thread::sleep(std::time::Duration::from_secs(60));
});

let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_millis(1))
.build()
.unwrap();
let err = client
.get(format!("http://{addr}"))
.send()
.await
.unwrap_err();
let twirp_err: TwirpErrorResponse = err.into();
assert_eq!(twirp_err.code, TwirpErrorCode::Unavailable);
}

#[test]
fn reqwest_builder_error_maps_to_invalid_argument() {
// An empty URL string triggers a builder error
let err = reqwest::Client::builder()
.build()
.unwrap()
.get("")
.build()
.unwrap_err();
let twirp_err: TwirpErrorResponse = err.into();
assert_eq!(twirp_err.code, TwirpErrorCode::InvalidArgument);
}

#[test]
fn twirp_error_response_serialization_skips_fields() {
let response = TwirpErrorResponse {
Expand Down
Loading