Skip to main content

ipregistry/
error.rs

1//! Error types returned by the client.
2
3use std::fmt;
4
5/// A convenient alias for results produced by this crate.
6pub type Result<T, E = Error> = std::result::Result<T, E>;
7
8/// The error type returned by all client operations.
9///
10/// Match on it to distinguish failures reported by the Ipregistry API from
11/// failures that happened on the client side:
12///
13/// ```no_run
14/// # async fn example() {
15/// use ipregistry::{Client, Error, ErrorCode};
16///
17/// let client = Client::new("YOUR_API_KEY");
18/// match client.lookup("8.8.8.8".parse::<std::net::IpAddr>().unwrap()).await {
19///     Ok(info) => println!("{:?}", info.location.country.name),
20///     Err(Error::Api(err)) if err.code == Some(ErrorCode::InsufficientCredits) => {
21///         // handle exhausted credits
22///     }
23///     Err(Error::Transport(err)) => {
24///         // handle network error, timeout, ...
25///     }
26///     Err(err) => eprintln!("{err}"),
27/// }
28/// # }
29/// ```
30#[derive(Debug, thiserror::Error)]
31#[non_exhaustive]
32pub enum Error {
33    /// The Ipregistry API reported a failure, such as an invalid IP address,
34    /// an exhausted credit balance, or throttling.
35    #[error(transparent)]
36    Api(#[from] ApiError),
37
38    /// The request could not be sent or the response could not be read: a
39    /// network error, a timeout, or a TLS failure. The underlying cause is
40    /// available through [`std::error::Error::source`].
41    #[error("ipregistry: transport error: {0}")]
42    Transport(#[from] reqwest::Error),
43
44    /// A successful response could not be decoded as the expected JSON shape.
45    #[error("ipregistry: failed to decode response: {0}")]
46    Decode(#[from] serde_json::Error),
47
48    /// The client was misconfigured, for example with a base URL that cannot
49    /// be parsed.
50    #[error("ipregistry: invalid configuration: {0}")]
51    Config(String),
52}
53
54impl Error {
55    /// Returns the underlying [`ApiError`] when the API reported the failure.
56    pub fn as_api(&self) -> Option<&ApiError> {
57        match self {
58            Error::Api(err) => Some(err),
59            _ => None,
60        }
61    }
62
63    /// Returns the typed [`ErrorCode`] when the API reported the failure with
64    /// a recognizable code.
65    pub fn code(&self) -> Option<&ErrorCode> {
66        self.as_api().and_then(|err| err.code.as_ref())
67    }
68}
69
70/// A failure reported by the Ipregistry API, such as an invalid IP address, an
71/// exhausted credit balance, or throttling.
72///
73/// In batch lookups, an `ApiError` may also describe the failure of a single
74/// entry rather than the whole request; per-entry errors carry no
75/// [`status`](ApiError::status).
76#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
77#[non_exhaustive]
78pub struct ApiError {
79    /// The error code returned by the API, or `None` when the response did not
80    /// carry a recognizable error payload. Unrecognized codes are preserved in
81    /// [`ErrorCode::Other`].
82    pub code: Option<ErrorCode>,
83    /// A human-readable description of the error.
84    pub message: String,
85    /// A suggestion on how to resolve the error, when available.
86    pub resolution: String,
87    /// The HTTP status of the failed request, or `None` for per-entry errors
88    /// in batch responses.
89    pub status: Option<u16>,
90}
91
92impl fmt::Display for ApiError {
93    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
94        f.write_str("ipregistry: ")?;
95        if self.message.is_empty() {
96            f.write_str("API error")?;
97        } else {
98            f.write_str(&self.message)?;
99        }
100        if let Some(code) = &self.code {
101            write!(f, " ({code})")?;
102        }
103        if !self.resolution.is_empty() {
104            write!(f, ": {}", self.resolution)?;
105        }
106        Ok(())
107    }
108}
109
110/// A strongly typed Ipregistry API error code. It lets callers branch on error
111/// conditions without matching on raw strings. See
112/// <https://ipregistry.co/docs/errors> for the authoritative list.
113///
114/// Codes not known to this version of the crate are preserved verbatim in
115/// [`ErrorCode::Other`].
116#[derive(Debug, Clone, PartialEq, Eq, Hash)]
117#[non_exhaustive]
118pub enum ErrorCode {
119    /// The request is malformed.
120    BadRequest,
121    /// The API key exists but is disabled.
122    DisabledApiKey,
123    /// Lookups for this IP address are not allowed for the API key.
124    ForbiddenIp,
125    /// Requests from this origin are not allowed for the API key.
126    ForbiddenOrigin,
127    /// Requests from this IP address or origin are not allowed.
128    ForbiddenIpOrigin,
129    /// The API encountered an internal error.
130    Internal,
131    /// The account has no credits left.
132    InsufficientCredits,
133    /// The API key is not valid.
134    InvalidApiKey,
135    /// The requested Autonomous System Number is malformed.
136    InvalidAsn,
137    /// The `fields` selection expression is malformed.
138    InvalidFilterSyntax,
139    /// The requested IP address is malformed.
140    InvalidIpAddress,
141    /// No API key was supplied.
142    MissingApiKey,
143    /// The requested Autonomous System Number is reserved.
144    ReservedAsn,
145    /// The requested IP address is reserved (private, loopback, ...).
146    ReservedIpAddress,
147    /// The batch request contains more ASNs than allowed.
148    TooManyAsns,
149    /// The batch request contains more IP addresses than allowed.
150    TooManyIps,
151    /// The API key is rate limited and the limit was exceeded.
152    TooManyRequests,
153    /// The request contains more User-Agent strings than allowed.
154    TooManyUserAgents,
155    /// The requested Autonomous System Number is unknown.
156    UnknownAsn,
157    /// An error code this version of the crate does not know about, preserved
158    /// verbatim.
159    Other(String),
160}
161
162impl ErrorCode {
163    /// Parses a raw API error code, mapping unrecognized codes to
164    /// [`ErrorCode::Other`].
165    pub fn from_raw(raw: &str) -> Self {
166        match raw.trim().to_ascii_uppercase().as_str() {
167            "BAD_REQUEST" => Self::BadRequest,
168            "DISABLED_API_KEY" => Self::DisabledApiKey,
169            "FORBIDDEN_IP" => Self::ForbiddenIp,
170            "FORBIDDEN_ORIGIN" => Self::ForbiddenOrigin,
171            "FORBIDDEN_IP_ORIGIN" => Self::ForbiddenIpOrigin,
172            "INTERNAL" => Self::Internal,
173            "INSUFFICIENT_CREDITS" => Self::InsufficientCredits,
174            "INVALID_API_KEY" => Self::InvalidApiKey,
175            "INVALID_ASN" => Self::InvalidAsn,
176            "INVALID_FILTER_SYNTAX" => Self::InvalidFilterSyntax,
177            "INVALID_IP_ADDRESS" => Self::InvalidIpAddress,
178            "MISSING_API_KEY" => Self::MissingApiKey,
179            "RESERVED_ASN" => Self::ReservedAsn,
180            "RESERVED_IP_ADDRESS" => Self::ReservedIpAddress,
181            "TOO_MANY_ASNS" => Self::TooManyAsns,
182            "TOO_MANY_IPS" => Self::TooManyIps,
183            "TOO_MANY_REQUESTS" => Self::TooManyRequests,
184            "TOO_MANY_USER_AGENTS" => Self::TooManyUserAgents,
185            "UNKNOWN_ASN" => Self::UnknownAsn,
186            _ => Self::Other(raw.to_owned()),
187        }
188    }
189
190    /// Returns the raw form of the code, as returned by the API.
191    pub fn as_str(&self) -> &str {
192        match self {
193            Self::BadRequest => "BAD_REQUEST",
194            Self::DisabledApiKey => "DISABLED_API_KEY",
195            Self::ForbiddenIp => "FORBIDDEN_IP",
196            Self::ForbiddenOrigin => "FORBIDDEN_ORIGIN",
197            Self::ForbiddenIpOrigin => "FORBIDDEN_IP_ORIGIN",
198            Self::Internal => "INTERNAL",
199            Self::InsufficientCredits => "INSUFFICIENT_CREDITS",
200            Self::InvalidApiKey => "INVALID_API_KEY",
201            Self::InvalidAsn => "INVALID_ASN",
202            Self::InvalidFilterSyntax => "INVALID_FILTER_SYNTAX",
203            Self::InvalidIpAddress => "INVALID_IP_ADDRESS",
204            Self::MissingApiKey => "MISSING_API_KEY",
205            Self::ReservedAsn => "RESERVED_ASN",
206            Self::ReservedIpAddress => "RESERVED_IP_ADDRESS",
207            Self::TooManyAsns => "TOO_MANY_ASNS",
208            Self::TooManyIps => "TOO_MANY_IPS",
209            Self::TooManyRequests => "TOO_MANY_REQUESTS",
210            Self::TooManyUserAgents => "TOO_MANY_USER_AGENTS",
211            Self::UnknownAsn => "UNKNOWN_ASN",
212            Self::Other(raw) => raw,
213        }
214    }
215}
216
217impl fmt::Display for ErrorCode {
218    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
219        f.write_str(self.as_str())
220    }
221}
222
223/// Mirrors the JSON error body returned by the API.
224#[derive(Debug, serde::Deserialize)]
225pub(crate) struct ApiErrorPayload {
226    pub(crate) code: String,
227    #[serde(default)]
228    pub(crate) message: String,
229    #[serde(default)]
230    pub(crate) resolution: String,
231}
232
233impl ApiErrorPayload {
234    /// Converts a decoded payload into a typed [`ApiError`].
235    pub(crate) fn into_api_error(self, status: Option<u16>) -> ApiError {
236        ApiError {
237            code: (!self.code.is_empty()).then(|| ErrorCode::from_raw(&self.code)),
238            message: self.message,
239            resolution: self.resolution,
240            status,
241        }
242    }
243}
244
245/// Converts a non-2xx response body into an [`ApiError`], falling back to a
246/// generic message when the body is not a recognizable error payload.
247pub(crate) fn parse_api_error(data: &[u8], status: u16) -> ApiError {
248    match serde_json::from_slice::<ApiErrorPayload>(data) {
249        Ok(payload) if !payload.code.is_empty() => payload.into_api_error(Some(status)),
250        _ => ApiError {
251            code: None,
252            message: format!("unexpected HTTP status {status}"),
253            resolution: String::new(),
254            status: Some(status),
255        },
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262
263    #[test]
264    fn error_code_roundtrip() {
265        let codes = [
266            "BAD_REQUEST",
267            "DISABLED_API_KEY",
268            "FORBIDDEN_IP",
269            "FORBIDDEN_ORIGIN",
270            "FORBIDDEN_IP_ORIGIN",
271            "INTERNAL",
272            "INSUFFICIENT_CREDITS",
273            "INVALID_API_KEY",
274            "INVALID_ASN",
275            "INVALID_FILTER_SYNTAX",
276            "INVALID_IP_ADDRESS",
277            "MISSING_API_KEY",
278            "RESERVED_ASN",
279            "RESERVED_IP_ADDRESS",
280            "TOO_MANY_ASNS",
281            "TOO_MANY_IPS",
282            "TOO_MANY_REQUESTS",
283            "TOO_MANY_USER_AGENTS",
284            "UNKNOWN_ASN",
285        ];
286        for raw in codes {
287            let code = ErrorCode::from_raw(raw);
288            assert!(!matches!(code, ErrorCode::Other(_)), "{raw} not recognized");
289            assert_eq!(code.as_str(), raw);
290        }
291    }
292
293    #[test]
294    fn error_code_is_case_and_space_insensitive() {
295        assert_eq!(
296            ErrorCode::from_raw(" invalid_api_key "),
297            ErrorCode::InvalidApiKey
298        );
299    }
300
301    #[test]
302    fn unknown_error_code_is_preserved() {
303        let code = ErrorCode::from_raw("SOMETHING_NEW");
304        assert_eq!(code, ErrorCode::Other("SOMETHING_NEW".to_owned()));
305        assert_eq!(code.as_str(), "SOMETHING_NEW");
306    }
307
308    #[test]
309    fn api_error_display() {
310        let err = ApiError {
311            code: Some(ErrorCode::InvalidIpAddress),
312            message: "the IP address is invalid".to_owned(),
313            resolution: "provide a valid IPv4 or IPv6 address".to_owned(),
314            status: Some(400),
315        };
316        assert_eq!(
317            err.to_string(),
318            "ipregistry: the IP address is invalid (INVALID_IP_ADDRESS): \
319             provide a valid IPv4 or IPv6 address"
320        );
321    }
322
323    #[test]
324    fn api_error_display_without_details() {
325        let err = ApiError {
326            code: None,
327            message: String::new(),
328            resolution: String::new(),
329            status: Some(502),
330        };
331        assert_eq!(err.to_string(), "ipregistry: API error");
332    }
333
334    #[test]
335    fn parse_api_error_with_payload() {
336        let body =
337            br#"{"code":"INVALID_API_KEY","message":"key is invalid","resolution":"fix it"}"#;
338        let err = parse_api_error(body, 403);
339        assert_eq!(err.code, Some(ErrorCode::InvalidApiKey));
340        assert_eq!(err.message, "key is invalid");
341        assert_eq!(err.resolution, "fix it");
342        assert_eq!(err.status, Some(403));
343    }
344
345    #[test]
346    fn parse_api_error_with_unrecognizable_body() {
347        let err = parse_api_error(b"<html>Bad Gateway</html>", 502);
348        assert_eq!(err.code, None);
349        assert_eq!(err.message, "unexpected HTTP status 502");
350        assert_eq!(err.status, Some(502));
351    }
352}