Skip to content

Commit 30bb74a

Browse files
authored
feat(api-client): always catch 4xx and 5xx errors regardless of body type (#2052)
* feat(api-client): always catch 4xx and 5xx errors regardless of body type * clip it
1 parent 855dd82 commit 30bb74a

File tree

8 files changed

+115
-67
lines changed

8 files changed

+115
-67
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ assert_cmd = "2.0.6"
2828
async-trait = "0.1.58"
2929
axum = { version = "0.8.1", default-features = false }
3030
bollard = { version = "0.18.1", features = ["ssl_providerless"] }
31+
bytes = "1"
3132
cargo_metadata = "0.19.1"
3233
chrono = { version = "0.4.34", default-features = false }
3334
clap = { version = "4.2.7", features = ["derive"] }

admin/src/client.rs

Lines changed: 12 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use anyhow::Result;
22
use serde_json::{json, Value};
3-
use shuttle_api_client::ShuttleApiClient;
3+
use shuttle_api_client::{util::ToBodyContent, ShuttleApiClient};
44
use shuttle_common::models::{
55
project::{ProjectResponse, ProjectUpdateRequest},
66
team::AddTeamMemberRequest,
@@ -91,44 +91,39 @@ impl Client {
9191
.await
9292
}
9393

94-
pub async fn add_team_member(&self, team_user_id: &str, user_id: String) -> Result<()> {
94+
pub async fn add_team_member(&self, team_user_id: &str, user_id: String) -> Result<String> {
9595
self.inner
96-
.post(
96+
.post_json(
9797
format!("/teams/{team_user_id}/members"),
9898
Some(AddTeamMemberRequest {
9999
user_id: Some(user_id),
100100
email: None,
101101
role: None,
102102
}),
103103
)
104-
.await?;
105-
106-
Ok(())
104+
.await
107105
}
108106

109107
pub async fn feature_flag(&self, entity: &str, flag: &str, set: bool) -> Result<()> {
110-
let resp = if set {
108+
if set {
111109
self.inner
112110
.put(
113111
format!("/admin/feature-flag/{entity}/{flag}"),
114112
Option::<()>::None,
115113
)
116114
.await?
115+
.to_empty()
116+
.await
117117
} else {
118118
self.inner
119119
.delete(
120120
format!("/admin/feature-flag/{entity}/{flag}"),
121121
Option::<()>::None,
122122
)
123123
.await?
124-
};
125-
126-
if !resp.status().is_success() {
127-
dbg!(resp);
128-
panic!("request failed");
124+
.to_empty()
125+
.await
129126
}
130-
131-
Ok(())
132127
}
133128

134129
pub async fn gc_free_tier(&self, days: u32) -> Result<Vec<String>> {
@@ -163,9 +158,9 @@ impl Client {
163158
format!("/admin/users/{user_id}/account_tier"),
164159
Some(UpdateAccountTierRequest { account_tier }),
165160
)
166-
.await?;
167-
168-
Ok(())
161+
.await?
162+
.to_empty()
163+
.await
169164
}
170165

171166
pub async fn get_expired_protrials(&self) -> Result<Vec<String>> {

api-client/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ shuttle-common = { workspace = true, features = ["models", "unknown-variants"] }
1212

1313
anyhow = { workspace = true }
1414
async-trait = { workspace = true }
15+
bytes = { workspace = true }
1516
headers = { workspace = true }
1617
http = { workspace = true }
1718
percent-encoding = { workspace = true }

api-client/src/lib.rs

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@ use crate::middleware::LoggingMiddleware;
3232
#[cfg(feature = "tracing")]
3333
use tracing::{debug, error};
3434

35-
mod util;
36-
use util::ToJson;
35+
pub mod util;
36+
use util::ToBodyContent;
3737

3838
#[derive(Clone)]
3939
pub struct ShuttleApiClient {
@@ -155,19 +155,20 @@ impl ShuttleApiClient {
155155
let r#type = resource_type.to_string();
156156
let r#type = utf8_percent_encode(&r#type, percent_encoding::NON_ALPHANUMERIC).to_owned();
157157

158-
let res = self
158+
let bytes = self
159159
.get(
160160
format!(
161161
"/projects/{project}/services/{project}/resources/{}/dump",
162162
r#type
163163
),
164164
Option::<()>::None,
165165
)
166-
.await?;
166+
.await?
167+
.to_bytes()
168+
.await?
169+
.to_vec();
167170

168-
let bytes = res.bytes().await?;
169-
170-
Ok(bytes.to_vec())
171+
Ok(bytes)
171172
}
172173

173174
pub async fn delete_service_resource(
@@ -301,15 +302,22 @@ impl ShuttleApiClient {
301302
self.get_json(path).await
302303
}
303304

304-
pub async fn reset_api_key(&self) -> Result<Response> {
305-
self.put("/users/reset-api-key", Option::<()>::None).await
305+
pub async fn reset_api_key(&self) -> Result<()> {
306+
self.put("/users/reset-api-key", Option::<()>::None)
307+
.await?
308+
.to_empty()
309+
.await
306310
}
307311

308312
pub async fn ws_get(
309313
&self,
310314
path: impl AsRef<str>,
311315
) -> Result<WebSocketStream<MaybeTlsStream<TcpStream>>> {
312-
let ws_url = self.api_url.clone().replace("http", "ws");
316+
let ws_url = self
317+
.api_url
318+
.clone()
319+
.replacen("http://", "ws://", 1)
320+
.replacen("https://", "wss://", 1);
313321
let url = format!("{ws_url}{}", path.as_ref());
314322
let mut req = url.into_client_request()?;
315323

api-client/src/util.rs

Lines changed: 74 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,95 @@
11
use anyhow::{Context, Result};
22
use async_trait::async_trait;
3+
use bytes::Bytes;
34
use http::StatusCode;
45
use serde::de::DeserializeOwned;
56
use shuttle_common::models::error::ApiError;
67

7-
/// A to_json wrapper for handling our error states
8+
/// Helpers for consuming and parsing response bodies and handling parsing of an ApiError if the response is 4xx/5xx
89
#[async_trait]
9-
pub trait ToJson {
10+
pub trait ToBodyContent {
1011
async fn to_json<T: DeserializeOwned>(self) -> Result<T>;
12+
async fn to_text(self) -> Result<String>;
13+
async fn to_bytes(self) -> Result<Bytes>;
14+
async fn to_empty(self) -> Result<()>;
15+
}
16+
17+
fn into_api_error(body: &str, status_code: StatusCode) -> ApiError {
18+
#[cfg(feature = "tracing")]
19+
tracing::trace!("Parsing response as API error");
20+
21+
let res: ApiError = match serde_json::from_str(body) {
22+
Ok(res) => res,
23+
_ => ApiError::new(
24+
format!("Failed to parse error response from the server:\n{}", body),
25+
status_code,
26+
),
27+
};
28+
29+
res
30+
}
31+
32+
/// Tries to convert bytes to string. If not possible, returns a string symbolizing the bytes and the length
33+
fn bytes_to_string_with_fallback(bytes: Bytes) -> String {
34+
String::from_utf8(bytes.to_vec()).unwrap_or_else(|_| format!("[{} bytes]", bytes.len()))
1135
}
1236

1337
#[async_trait]
14-
impl ToJson for reqwest::Response {
38+
impl ToBodyContent for reqwest::Response {
1539
async fn to_json<T: DeserializeOwned>(self) -> Result<T> {
1640
let status_code = self.status();
1741
let bytes = self.bytes().await?;
18-
let string = String::from_utf8(bytes.to_vec())
19-
.unwrap_or_else(|_| format!("[{} bytes]", bytes.len()));
42+
let string = bytes_to_string_with_fallback(bytes);
2043

2144
#[cfg(feature = "tracing")]
2245
tracing::trace!(response = %string, "Parsing response as JSON");
2346

24-
if matches!(
25-
status_code,
26-
StatusCode::OK | StatusCode::SWITCHING_PROTOCOLS
27-
) {
28-
serde_json::from_str(&string).context("failed to parse a successful response")
29-
} else {
30-
#[cfg(feature = "tracing")]
31-
tracing::trace!("Parsing response as API error");
32-
33-
let res: ApiError = match serde_json::from_str(&string) {
34-
Ok(res) => res,
35-
_ => ApiError::new(
36-
format!(
37-
"Failed to parse error response from the server:\n{}",
38-
string
39-
),
40-
status_code,
41-
),
42-
};
43-
44-
Err(res.into())
47+
if status_code.is_client_error() || status_code.is_server_error() {
48+
return Err(into_api_error(&string, status_code).into());
49+
}
50+
51+
serde_json::from_str(&string).context("failed to parse a successful response")
52+
}
53+
54+
async fn to_text(self) -> Result<String> {
55+
let status_code = self.status();
56+
let bytes = self.bytes().await?;
57+
let string = bytes_to_string_with_fallback(bytes);
58+
59+
#[cfg(feature = "tracing")]
60+
tracing::trace!(response = %string, "Parsing response as text");
61+
62+
if status_code.is_client_error() || status_code.is_server_error() {
63+
return Err(into_api_error(&string, status_code).into());
4564
}
65+
66+
Ok(string)
67+
}
68+
69+
async fn to_bytes(self) -> Result<Bytes> {
70+
let status_code = self.status();
71+
let bytes = self.bytes().await?;
72+
73+
#[cfg(feature = "tracing")]
74+
tracing::trace!(response_length = bytes.len(), "Got response bytes");
75+
76+
if status_code.is_client_error() || status_code.is_server_error() {
77+
let string = bytes_to_string_with_fallback(bytes);
78+
return Err(into_api_error(&string, status_code).into());
79+
}
80+
81+
Ok(bytes)
82+
}
83+
84+
async fn to_empty(self) -> Result<()> {
85+
let status_code = self.status();
86+
87+
if status_code.is_client_error() || status_code.is_server_error() {
88+
let bytes = self.bytes().await?;
89+
let string = bytes_to_string_with_fallback(bytes);
90+
return Err(into_api_error(&string, status_code).into());
91+
}
92+
93+
Ok(())
4694
}
4795
}

cargo-shuttle/src/lib.rs

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -862,7 +862,8 @@ impl Shuttle {
862862

863863
async fn logout(&mut self, logout_args: LogoutArgs) -> Result<()> {
864864
if logout_args.reset_api_key {
865-
self.reset_api_key().await?;
865+
let client = self.client.as_ref().unwrap();
866+
client.reset_api_key().await.context("Resetting API key")?;
866867
eprintln!("Successfully reset the API key.");
867868
}
868869
self.ctx.clear_api_key()?;
@@ -872,17 +873,6 @@ impl Shuttle {
872873
Ok(())
873874
}
874875

875-
async fn reset_api_key(&self) -> Result<()> {
876-
let client = self.client.as_ref().unwrap();
877-
client.reset_api_key().await.and_then(|res| {
878-
if res.status().is_success() {
879-
Ok(())
880-
} else {
881-
Err(anyhow!("Resetting API key failed."))
882-
}
883-
})
884-
}
885-
886876
async fn stop(&self, tracking_args: DeploymentTrackingArgs) -> Result<()> {
887877
let client = self.client.as_ref().unwrap();
888878
let pid = self.ctx.project_id();

runtime/README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,14 @@
1212

1313
## Usage
1414

15-
Start by installing the Shuttle CLI by running the following in a terminal:
15+
Start by installing the [Shuttle CLI](https://crates.io/crates/cargo-shuttle) by running the following in a terminal ([more installation options](https://docs.shuttle.dev/getting-started/installation)):
1616

1717
```bash
18-
cargo install cargo-shuttle
18+
# Linux / macOS
19+
curl -sSfL https://www.shuttle.dev/install | bash
20+
21+
# Windows (Powershell)
22+
iwr https://www.shuttle.dev/install-win | iex
1923
```
2024

2125
Now that Shuttle is installed, you can initialize a project with Axum boilerplate:

0 commit comments

Comments
 (0)