1use reqwest::StatusCode;
2use synd_protocol::session::{
3 CloseSessionErrorResponse, OpenSessionErrorResponse, RenewSessionErrorResponse,
4};
5use thiserror::Error;
6use tokio_tungstenite::tungstenite;
7use url::Url;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum Retryability {
11 Retryable,
12 Permanent,
13}
14
15#[derive(Error, Debug)]
16pub enum SyndApiError {
17 #[error("unauthorized")]
18 Unauthorized { url: Option<Url> },
19 #[error(transparent)]
20 BuildRequest(reqwest::Error),
21 #[error(transparent)]
22 SendRequest(reqwest::Error),
23 #[error(transparent)]
24 DecodeResponse(reqwest::Error),
25 #[error("HTTP status client error ({status}) for url ({url})", url = url.as_ref().map(ToString::to_string).unwrap_or_default())]
26 HttpStatus {
27 status: StatusCode,
28 url: Option<Url>,
29 },
30 #[error("graphql error: {errors:?}")]
31 Graphql { errors: Vec<graphql_client::Error> },
32 #[error("session open rejected: {0}")]
33 OpenSession(OpenSessionErrorResponse),
34 #[error("session renew rejected: {0}")]
35 RenewSession(RenewSessionErrorResponse),
36 #[error("session close rejected: {0}")]
37 CloseSession(CloseSessionErrorResponse),
38 #[error("credential is not configured")]
39 MissingCredential,
40 #[error("invalid authorization header")]
41 InvalidHeader(#[from] reqwest::header::InvalidHeaderValue),
42 #[error("invalid url")]
43 InvalidUrl(#[from] url::ParseError),
44 #[error("invalid root certificate: {message}")]
45 InvalidRootCertificate { message: String },
46 #[error("invalid TLS configuration: {message}")]
47 TlsConfiguration { message: String },
48 #[error("websocket error: {0}")]
49 WebSocket(#[from] tungstenite::Error),
50 #[error("json error: {0}")]
51 Json(#[from] serde_json::Error),
52 #[error("unexpected response: {context}")]
53 UnexpectedResponse { context: &'static str },
54 #[error("unsupported GraphQL subscription endpoint scheme: {scheme}")]
55 UnsupportedWebSocketScheme { scheme: String },
56 #[error("failed to set websocket scheme")]
57 SetWebSocketScheme,
58 #[error("GraphQL subscription protocol error: {message}")]
59 SubscriptionProtocol { message: String },
60 #[error("feed-event watch closed (code {code:?}): {reason}")]
61 FeedEventWatchClosed { code: Option<u16>, reason: String },
62}
63
64impl SyndApiError {
65 pub fn retryability(&self) -> Retryability {
66 match self {
67 Self::SendRequest(error) if Self::request_error_is_retryable(error) => {
68 Retryability::Retryable
69 }
70 Self::DecodeResponse(error) if Self::response_error_is_retryable(error) => {
71 Retryability::Retryable
72 }
73 Self::FeedEventWatchClosed { code, .. } if Self::close_code_is_retryable(*code) => {
74 Retryability::Retryable
75 }
76 Self::HttpStatus { status, .. }
77 if status.is_server_error() || matches!(status.as_u16(), 408 | 425 | 429) =>
78 {
79 Retryability::Retryable
80 }
81 Self::WebSocket(error) if Self::websocket_error_is_retryable(error) => {
82 Retryability::Retryable
83 }
84 Self::Unauthorized { .. }
85 | Self::BuildRequest(_)
86 | Self::SendRequest(_)
87 | Self::DecodeResponse(_)
88 | Self::HttpStatus { .. }
89 | Self::Graphql { .. }
90 | Self::OpenSession(_)
91 | Self::RenewSession(_)
92 | Self::CloseSession(_)
93 | Self::MissingCredential
94 | Self::InvalidHeader(_)
95 | Self::InvalidUrl(_)
96 | Self::InvalidRootCertificate { .. }
97 | Self::TlsConfiguration { .. }
98 | Self::WebSocket(_)
99 | Self::Json(_)
100 | Self::UnexpectedResponse { .. }
101 | Self::UnsupportedWebSocketScheme { .. }
102 | Self::SetWebSocketScheme
103 | Self::SubscriptionProtocol { .. }
104 | Self::FeedEventWatchClosed { .. } => Retryability::Permanent,
105 }
106 }
107
108 pub(super) fn from_send_error(error: reqwest::Error) -> Self {
109 if error.is_builder() {
110 Self::BuildRequest(error)
111 } else {
112 Self::SendRequest(error)
113 }
114 }
115
116 pub(super) fn from_status_error(error: reqwest::Error) -> Self {
117 match error.status() {
118 Some(StatusCode::UNAUTHORIZED) => Self::Unauthorized {
119 url: error.url().cloned(),
120 },
121 Some(status) => Self::HttpStatus {
122 status,
123 url: error.url().cloned(),
124 },
125 None => Self::from_send_error(error),
126 }
127 }
128
129 fn websocket_error_is_retryable(error: &tungstenite::Error) -> bool {
130 match error {
131 tungstenite::Error::ConnectionClosed | tungstenite::Error::Io(_) => true,
132 tungstenite::Error::Http(response) => {
133 response.status().is_server_error()
134 || matches!(response.status().as_u16(), 408 | 425 | 429)
135 }
136 tungstenite::Error::AlreadyClosed
137 | tungstenite::Error::Tls(_)
138 | tungstenite::Error::Capacity(_)
139 | tungstenite::Error::Protocol(_)
140 | tungstenite::Error::WriteBufferFull(_)
141 | tungstenite::Error::Utf8(_)
142 | tungstenite::Error::AttackAttempt
143 | tungstenite::Error::Url(_)
144 | tungstenite::Error::HttpFormat(_) => false,
145 }
146 }
147
148 fn request_error_is_retryable(error: &reqwest::Error) -> bool {
149 !error.is_redirect()
150 && (error.is_connect() || error.is_timeout() || error.is_body() || error.is_request())
151 }
152
153 fn response_error_is_retryable(error: &reqwest::Error) -> bool {
154 error.is_connect() || error.is_timeout() || (error.is_body() && !error.is_decode())
155 }
156
157 fn close_code_is_retryable(code: Option<u16>) -> bool {
158 match code {
159 None | Some(1000 | 1001 | 1006 | 1011 | 1012 | 1013 | 1014 | 4408 | 4500 | 4504) => {
160 true
161 }
162 Some(_) => false,
163 }
164 }
165}