Skip to content

Commit d4d571d

Browse files
authored
MPP-4337 - feat(relay): improve API error handling to extract error c… (#6992)
* MPP-4337 - feat(relay): improve API error handling to extract error code and details from JSON Update Relay error handling logic to parse both `error_code` and `detail` fields from API error responses. This enables propagating the exact error code (if present) alongside the detail message when reporting API errors. Introduces a helper to extract JSON API errors and updates tests to cover new parsing logic for various error payload shapes. * MPP-4337 - feat(relay): add HTTP status to errors - Add `status: u16` to `RelayApiError::Api` and `Error::RelayApi` for richer error context. - Update error reporting to expose `status`, `code`, and `detail` fields, allowing downstream users to inspect both raw server data and structured API error codes.
1 parent 227ea99 commit d4d571d

File tree

3 files changed

+168
-28
lines changed

3 files changed

+168
-28
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@
1515
- Updated the ApiError enum to AdsClientApiError to avoid naming collision.
1616
- The `context_id` is now generated and rotated via the existing eponym component crate.
1717

18+
### Relay
19+
- **⚠️ Breaking Change:** The error handling for the Relay component has been refactored for stronger forward compatibility and more transparent error reporting in Swift and Kotlin via UniFFI.
20+
- API and network errors from the Relay server are now converted to a single `RelayApiError::Api { status, code, detail }` variant, exposing the HTTP status code, a machine-readable error code (if present), and a human-readable detail message.
21+
- Downstream client apps can now handle server errors based on both the `status` and `error_code` fields directly, without additional changes to the Rust component - even as server-side error codes evolve.
22+
- **Consumers must update their error handling code to match the new `Api { status, code, detail }` shape.**
23+
1824
# v144.0 (_2025-09-15_)
1925

2026
## ✨ What's New ✨

components/relay/src/error.rs

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,28 @@ pub enum RelayApiError {
1313
#[error("Relay network error: {reason}")]
1414
Network { reason: String },
1515

16-
#[error("Relay API error: {detail}")]
17-
RelayApi { detail: String },
16+
#[error("Relay API error (status {status} [{code}]): {detail}")]
17+
Api {
18+
status: u16,
19+
code: String,
20+
detail: String,
21+
},
1822

1923
#[error("Relay unexpected error: {reason}")]
2024
Other { reason: String },
2125
}
2226

27+
// Helper for extracting "code" and "detail" from JSON responses
28+
#[derive(Debug, serde::Deserialize)]
29+
struct ApiErrorJson {
30+
error_code: Option<String>,
31+
detail: Option<String>,
32+
}
33+
2334
#[derive(Debug, thiserror::Error)]
2435
pub enum Error {
25-
#[error("Relay API error: {0}")]
26-
RelayApi(String),
36+
#[error("Relay API error: {status} {body}")]
37+
RelayApi { status: u16, body: String },
2738

2839
#[error("JSON parsing error: {0}")]
2940
Json(#[from] serde_json::Error),
@@ -44,10 +55,24 @@ impl GetErrorHandling for Error {
4455
})
4556
.log_warning()
4657
}
47-
Error::RelayApi(detail) => ErrorHandling::convert(RelayApiError::RelayApi {
48-
detail: detail.clone(),
49-
})
50-
.report_error("relay-api-error"),
58+
Error::RelayApi { status, body } => {
59+
// Accept {"error_code", "detail"} JSON or just "detail"
60+
let parsed: Option<ApiErrorJson> = serde_json::from_str(body).ok();
61+
let code = parsed
62+
.as_ref()
63+
.and_then(|j| j.error_code.clone())
64+
.unwrap_or_else(|| "unknown".to_string());
65+
let detail = parsed
66+
.as_ref()
67+
.and_then(|j| j.detail.clone())
68+
.unwrap_or_else(|| body.clone());
69+
ErrorHandling::convert(RelayApiError::Api {
70+
status: *status,
71+
code,
72+
detail,
73+
})
74+
.report_error("relay-api-error")
75+
}
5176
_ => ErrorHandling::convert(RelayApiError::Other {
5277
reason: self.to_string(),
5378
})

components/relay/src/lib.rs

Lines changed: 129 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -70,11 +70,6 @@ struct CreateAddressPayload<'a> {
7070
used_on: &'a str,
7171
}
7272

73-
#[derive(Deserialize)]
74-
struct RelayApiErrorMessage {
75-
detail: String,
76-
}
77-
7873
impl RelayClient {
7974
fn build_url(&self, path: &str) -> Result<Url> {
8075
Ok(Url::parse(&format!("{}{}", self.server_url, path))?)
@@ -111,21 +106,28 @@ impl RelayClient {
111106

112107
/// Retrieves all Relay addresses associated with the current account.
113108
///
114-
/// Returns a vector of [`RelayAddress`] objects on success, or an error if the request fails.
109+
/// Returns a vector of [`RelayAddress`] objects on success.
110+
///
111+
/// ## Errors
115112
///
116-
/// ## Known Limitations
117-
/// - Will return an error if the Relay user record doesn't exist yet (see [`accept_terms`]).
118-
/// - Error variants are subject to server-side changes.
113+
/// - `RelayApi`: Returned for any non-successful (non-2xx) HTTP response. Provides the HTTP `status` and response `body`; downstream consumers can inspect these fields. If the response body is JSON with `error_code` or `detail` fields, these are parsed and included for more granular handling; otherwise, the raw response text is used as the error detail.
114+
/// - `Network`: Returned for transport-level failures, like loss of connectivity, with details in `reason`.
115+
/// - Other variants may be returned for unexpected deserialization, URL, or backend errors.
119116
#[handle_error(Error)]
120117
pub fn fetch_addresses(&self) -> ApiResult<Vec<RelayAddress>> {
121118
let url = self.build_url("/api/v1/relayaddresses/")?;
122119
let request = self.prepare_request(Method::Get, url)?;
123120

124121
let response = request.send()?;
122+
let status = response.status;
125123
let body = response.text();
126124
log::trace!("response text: {}", body);
127-
if let Ok(parsed) = serde_json::from_str::<RelayApiErrorMessage>(&body) {
128-
return Err(Error::RelayApi(parsed.detail));
125+
126+
if status >= 400 {
127+
return Err(Error::RelayApi {
128+
status,
129+
body: body.to_string(),
130+
});
129131
}
130132

131133
let addresses: Vec<RelayAddress> = response.json()?;
@@ -136,17 +138,27 @@ impl RelayClient {
136138
///
137139
/// This function was originally used to signal acceptance of terms and privacy notices,
138140
/// but now primarily serves to provision (create) the Relay user record if one does not exist.
139-
/// Returns `Ok(())` on success, or an error if the server call fails.
141+
///
142+
/// ## Errors
143+
///
144+
/// - `RelayApi`: Returned for any non-successful (non-2xx) HTTP response. Provides the HTTP `status` and response `body`; downstream consumers can inspect these fields. If the response body is JSON with `error_code` or `detail` fields, these are parsed and included for more granular handling; otherwise, the raw response text is used as the error detail.
145+
/// - `Network`: Returned for transport-level failures, like loss of connectivity, with details in `reason`.
146+
/// - Other variants may be returned for unexpected deserialization, URL, or backend errors.
140147
#[handle_error(Error)]
141148
pub fn accept_terms(&self) -> ApiResult<()> {
142149
let url = self.build_url("/api/v1/terms-accepted-user/")?;
143150
let request = self.prepare_request(Method::Post, url)?;
144151

145152
let response = request.send()?;
153+
let status = response.status;
146154
let body = response.text();
147155
log::trace!("response text: {}", body);
148-
if let Ok(parsed) = serde_json::from_str::<RelayApiErrorMessage>(&body) {
149-
return Err(Error::RelayApi(parsed.detail));
156+
157+
if status >= 400 {
158+
return Err(Error::RelayApi {
159+
status,
160+
body: body.to_string(),
161+
});
150162
}
151163
Ok(())
152164
}
@@ -159,11 +171,11 @@ impl RelayClient {
159171
/// - `generated_for`: The website for which the address is generated.
160172
/// - `used_on`: Comma-separated list of all websites where this address is used. Only updated by some clients.
161173
///
162-
/// ## Open Questions
163-
/// - See the spike doc and project Jira for clarifications on field semantics.
164-
/// - Returned error codes are not fully documented.
174+
/// ## Errors
165175
///
166-
/// Returns the newly created [`RelayAddress`] on success, or an error.
176+
/// - `RelayApi`: Returned for any non-successful (non-2xx) HTTP response. Provides the HTTP `status` and response `body`; downstream consumers can inspect these fields. If the response body is JSON with `error_code` or `detail` fields, these are parsed and included for more granular handling; otherwise, the raw response text is used as the error detail.
177+
/// - `Network`: Returned for transport-level failures, like loss of connectivity, with details in `reason`.
178+
/// - Other variants may be returned for unexpected deserialization, URL, or backend errors.
167179
#[handle_error(Error)]
168180
pub fn create_address(
169181
&self,
@@ -184,10 +196,15 @@ impl RelayClient {
184196
request = request.json(&payload);
185197

186198
let response = request.send()?;
199+
let status = response.status;
187200
let body = response.text();
188201
log::trace!("response text: {}", body);
189-
if let Ok(parsed) = serde_json::from_str::<RelayApiErrorMessage>(&body) {
190-
return Err(Error::RelayApi(parsed.detail));
202+
203+
if status >= 400 {
204+
return Err(Error::RelayApi {
205+
status,
206+
body: body.to_string(),
207+
});
191208
}
192209

193210
let address: RelayAddress = response.json()?;
@@ -228,6 +245,98 @@ mod tests {
228245
)
229246
}
230247

248+
#[test]
249+
fn test_fetch_addresses_permission_denied_relay_account() {
250+
viaduct_dev::init_backend_dev();
251+
252+
let error_json = r#"{"detail": "Authenticated user does not have a Relay account. Have they accepted the terms?"}"#;
253+
let _mock = mock("GET", "/api/v1/relayaddresses/")
254+
.with_status(403)
255+
.with_header("content-type", "application/json")
256+
.with_body(error_json)
257+
.create();
258+
259+
let client = RelayClient::new(mockito::server_url(), Some("mock_token".to_string()));
260+
let result = client.expect("success").fetch_addresses();
261+
262+
match result {
263+
Err(RelayApiError::Api {
264+
status,
265+
code,
266+
detail,
267+
}) => {
268+
assert_eq!(status, 403);
269+
assert_eq!(code, "unknown"); // No error_code present in JSON
270+
assert_eq!(
271+
detail,
272+
"Authenticated user does not have a Relay account. Have they accepted the terms?"
273+
);
274+
}
275+
other => panic!("Expected RelayApiError::Api but got {:?}", other),
276+
}
277+
}
278+
279+
#[test]
280+
fn test_accept_terms_parse_error_missing_token() {
281+
viaduct_dev::init_backend_dev();
282+
283+
let error_json = r#"{"detail": "Missing FXA Token after 'Bearer'."}"#;
284+
let _mock = mock("POST", "/api/v1/terms-accepted-user/")
285+
.with_status(400)
286+
.with_header("content-type", "application/json")
287+
.with_body(error_json)
288+
.create();
289+
290+
let client = RelayClient::new(mockito::server_url(), None);
291+
let result = client.expect("success").accept_terms();
292+
293+
match result {
294+
Err(RelayApiError::Api {
295+
status,
296+
code,
297+
detail,
298+
}) => {
299+
assert_eq!(status, 400);
300+
assert_eq!(code, "unknown"); // No error_code present in JSON
301+
assert_eq!(detail, "Missing FXA Token after 'Bearer'.");
302+
}
303+
other => panic!("Expected RelayApiError::Api but got {:?}", other),
304+
}
305+
}
306+
307+
#[test]
308+
fn test_create_address_free_tier_limit() {
309+
viaduct_dev::init_backend_dev();
310+
311+
let error_json = r#"{"error_code": "free_tier_limit", "detail": "You’ve used all 5 email masks included with your free account."}"#;
312+
let _mock = mock("POST", "/api/v1/relayaddresses/")
313+
.with_status(403)
314+
.with_header("content-type", "application/json")
315+
.with_body(error_json)
316+
.create();
317+
318+
let client = RelayClient::new(mockito::server_url(), Some("mock_token".to_string()));
319+
let result = client
320+
.expect("success")
321+
.create_address("Label", "example.com", "example.com");
322+
323+
match result {
324+
Err(RelayApiError::Api {
325+
status,
326+
code,
327+
detail,
328+
}) => {
329+
assert_eq!(status, 403);
330+
assert_eq!(code, "free_tier_limit");
331+
assert_eq!(
332+
detail,
333+
"You’ve used all 5 email masks included with your free account."
334+
);
335+
}
336+
other => panic!("Expected RelayApiError::Api but got {:?}", other),
337+
}
338+
}
339+
231340
#[test]
232341
fn test_fetch_addresses() {
233342
viaduct_dev::init_backend_dev();

0 commit comments

Comments
 (0)