Skip to content

Commit bd5be60

Browse files
committed
Gracefully handle out-of-range error codes
Fixes #22.
1 parent 96352e5 commit bd5be60

2 files changed

Lines changed: 146 additions & 13 deletions

File tree

src/api/camera/image_array/client.rs

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -192,9 +192,9 @@ impl Response for ASCOMResult<ImageArray> {
192192
);
193193
Ok(ndarray::Array::from_shape_vec(shape, data)?.into())
194194
} else {
195-
Err(ASCOMError::new(
196-
ASCOMErrorCode::try_from(u16::try_from(metadata.error_number)?)?,
197-
std::str::from_utf8(raw_data)?.to_owned(),
195+
Err(ASCOMError::new_with_unbounded_code(
196+
metadata.error_number,
197+
&String::from_utf8_lossy(raw_data),
198198
))
199199
};
200200
Ok(ResponseWithTransaction {
@@ -207,6 +207,7 @@ impl Response for ASCOMResult<ImageArray> {
207207
#[cfg(test)]
208208
mod tests {
209209
use super::*;
210+
use std::assert_matches;
210211

211212
/// Build an `application/imagebytes` payload at the given byte
212213
/// offset within a `Vec<u8>`. The `Vec`'s base allocation is
@@ -285,4 +286,28 @@ mod tests {
285286
.unwrap_or_else(|e| panic!("leading_pad={leading_pad}: {e:?}"));
286287
}
287288
}
289+
290+
/// Regression test for issue #22: an `imagebytes` body carrying an HRESULT-style
291+
/// `error_number` outside Alpaca's valid range must parse into an `UNSPECIFIED`
292+
/// error (preserving the message) rather than failing the whole response.
293+
#[test]
294+
fn from_reqwest_maps_out_of_range_error_number_to_unspecified() -> eyre::Result<()> {
295+
let metadata = ImageBytesMetadata {
296+
metadata_version: 1_i32,
297+
error_number: -2_147_024_882_i32,
298+
data_start: i32::try_from(size_of::<ImageBytesMetadata>())?,
299+
..bytemuck::Zeroable::zeroed()
300+
};
301+
let mut buf = bytemuck::bytes_of(&metadata).to_vec();
302+
buf.extend_from_slice(b"Not enough storage");
303+
304+
let mime: Mime = IMAGE_BYTES_TYPE.parse()?;
305+
let parsed = <ASCOMResult<ImageArray> as Response>::from_reqwest(mime, &buf)?;
306+
assert_matches!(parsed.response, Err(ASCOMError {
307+
code: ASCOMErrorCode::UNSPECIFIED,
308+
message,
309+
}) if message.contains("Not enough storage"));
310+
311+
Ok(())
312+
}
288313
}

src/errors.rs

Lines changed: 118 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use serde::{Deserialize, Serialize};
22
use std::borrow::Cow;
3-
use std::fmt::{self, Debug, Display, Formatter};
3+
use std::fmt::{self, Debug, Display, Formatter, UpperHex};
44
use std::ops::RangeInclusive;
55
use thiserror::Error;
66

@@ -11,24 +11,44 @@ const DRIVER_BASE: u16 = 0x500;
1111
/// The maximum value for error numbers.
1212
const MAX: u16 = 0xFFF;
1313

14+
/// The valid range of error numbers.
15+
const RANGE: RangeInclusive<u16> = BASE..=MAX;
16+
17+
fn invalid_error_code(raw: impl UpperHex) -> eyre::Error {
18+
eyre::eyre!("Error code {raw:#X} is out of valid range ({RANGE:#X?})")
19+
}
20+
1421
/// Alpaca representation of an ASCOM error code.
1522
#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize, derive_more::Display)]
1623
#[display("{self:?}")]
1724
#[serde(transparent)]
1825
pub struct ASCOMErrorCode(u16);
1926

27+
impl TryFrom<i32> for ASCOMErrorCode {
28+
type Error = eyre::Error;
29+
30+
/// Convert a raw error code into an `ASCOMErrorCode` if it's in the valid range.
31+
fn try_from(raw: i32) -> eyre::Result<Self> {
32+
if let Ok(raw) = u16::try_from(raw)
33+
&& RANGE.contains(&raw)
34+
{
35+
Ok(Self(raw))
36+
} else {
37+
Err(invalid_error_code(raw))
38+
}
39+
}
40+
}
41+
2042
impl TryFrom<u16> for ASCOMErrorCode {
2143
type Error = eyre::Error;
2244

2345
/// Convert a raw error code into an `ASCOMErrorCode` if it's in the valid range.
2446
fn try_from(raw: u16) -> eyre::Result<Self> {
25-
const RANGE: RangeInclusive<u16> = BASE..=MAX;
26-
27-
eyre::ensure!(
28-
RANGE.contains(&raw),
29-
"Error code {raw:#X} is out of valid range ({RANGE:#X?})"
30-
);
31-
Ok(Self(raw))
47+
if RANGE.contains(&raw) {
48+
Ok(Self(raw))
49+
} else {
50+
Err(invalid_error_code(raw))
51+
}
3252
}
3353
}
3454

@@ -95,7 +115,7 @@ impl ASCOMErrorCode {
95115
}
96116

97117
/// ASCOM error.
98-
#[derive(Debug, Clone, Serialize, Deserialize, Error)]
118+
#[derive(Debug, Clone, Serialize, Error)]
99119
#[error("ASCOM error {code}: {message}")]
100120
pub struct ASCOMError {
101121
/// Error number.
@@ -106,6 +126,33 @@ pub struct ASCOMError {
106126
pub message: Cow<'static, str>,
107127
}
108128

129+
impl<'de> Deserialize<'de> for ASCOMError {
130+
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
131+
#[derive(Deserialize)]
132+
#[serde(rename_all = "PascalCase")]
133+
struct Repr {
134+
error_number: i32,
135+
error_message: Cow<'static, str>,
136+
}
137+
138+
let Repr {
139+
error_number,
140+
error_message,
141+
} = Repr::deserialize(deserializer)?;
142+
143+
// A zero error number is success; anything else goes through the shared out-of-range
144+
// handling so the JSON and `imagebytes` paths agree (issue #22).
145+
Ok(if error_number == 0 {
146+
Self {
147+
code: ASCOMErrorCode::OK,
148+
message: error_message,
149+
}
150+
} else {
151+
Self::new_with_unbounded_code(error_number, &error_message)
152+
})
153+
}
154+
}
155+
109156
impl ASCOMError {
110157
/// Create a new `ASCOMError` from given error code and a message.
111158
pub fn new(code: ASCOMErrorCode, message: impl Display) -> Self {
@@ -114,6 +161,23 @@ impl ASCOMError {
114161
message: format!("{message:#}").into(),
115162
}
116163
}
164+
165+
/// Create an `ASCOMError` from a raw, non-zero error number that may fall outside Alpaca's
166+
/// valid range.
167+
///
168+
/// Drivers occasionally report HRESULT-style codes (e.g. `-2147024882`) that don't fit.
169+
///
170+
/// Instead of failing hard, which would lose the original error message (see issue #22),
171+
/// we surface those as `UNSPECIFIED` with the range error prepended to the main one.
172+
pub(crate) fn new_with_unbounded_code(raw_code: i32, message: &str) -> Self {
173+
match ASCOMErrorCode::try_from(raw_code) {
174+
Ok(code) => Self::new(code, message),
175+
Err(err) => Self::new(
176+
ASCOMErrorCode::UNSPECIFIED,
177+
format_args!("{err}: {message}"),
178+
),
179+
}
180+
}
117181
}
118182

119183
/// Result type for ASCOM methods.
@@ -186,7 +250,7 @@ ascom_error_codes! {
186250
// Extra codes for internal use only.
187251

188252
/// Reserved 'catch-all' error code (0x4FF) used when nothing else was specified.
189-
UNSPECIFIED = 0x4FF,
253+
pub(crate) UNSPECIFIED = 0x4FF,
190254
}
191255

192256
impl ASCOMError {
@@ -206,3 +270,47 @@ impl ASCOMError {
206270
Self::new(ASCOMErrorCode::UNSPECIFIED, message)
207271
}
208272
}
273+
274+
#[cfg(test)]
275+
mod tests {
276+
use super::{ASCOMError, ASCOMErrorCode};
277+
use std::assert_matches;
278+
279+
// Devices sometimes return HRESULT-style error numbers that don't fit `u16`. These must
280+
// deserialize as `UNSPECIFIED` (keeping the message) rather than failing the response (#22).
281+
#[test]
282+
fn deserializes_out_of_range_error_number_as_unspecified() -> eyre::Result<()> {
283+
assert_matches!(
284+
serde_json::from_str(r#"{"ErrorNumber": -2147024882, "ErrorMessage": "Not enough storage"}"#)?,
285+
ASCOMError {
286+
code: ASCOMErrorCode::UNSPECIFIED,
287+
message,
288+
} if message.contains("out of valid range") && message.contains("Not enough storage")
289+
);
290+
291+
Ok(())
292+
}
293+
294+
// In-range codes (including success `0`, which is outside the `0x400..=0xFFF` range but is a
295+
// valid `u16`) must round-trip unchanged so the client can still detect `OK` responses.
296+
#[test]
297+
fn deserializes_in_range_error_numbers_unchanged() -> eyre::Result<()> {
298+
assert_matches!(
299+
serde_json::from_str(r#"{"ErrorNumber": 0, "ErrorMessage": ""}"#)?,
300+
ASCOMError {
301+
code: ASCOMErrorCode::OK,
302+
..
303+
}
304+
);
305+
306+
assert_matches!(
307+
serde_json::from_str(r#"{"ErrorNumber": 1025, "ErrorMessage": "bad"}"#)?,
308+
ASCOMError {
309+
code: ASCOMErrorCode::INVALID_VALUE,
310+
..
311+
}
312+
);
313+
314+
Ok(())
315+
}
316+
}

0 commit comments

Comments
 (0)