1use std::{error::Error, sync::Arc, time::Duration};
4
5use prost::Message;
6use reqwest::StatusCode;
7use uuid::Uuid;
8use wowlab_common::node_http::{
9 NodeChunkCompletionResponse, NodeRegistrationRequest, NodeRegistrationResponse,
10 NodeTokenResponse, NodeUnlinkResponse, NodeWorkContextResponse,
11};
12use wowlab_types::{
13 constants::{HTTP_CONNECT_TIMEOUT_SECS, HTTP_REQUEST_TIMEOUT_SECS, MAX_RETRY_ATTEMPTS},
14 proto::BatchChunkCompletion,
15 sensitive::Sensitive,
16};
17
18mod request;
19
20use request::{ExecutorTarget, NodeRequest, RequestExecutor};
21
22type BoxError = Box<dyn Error + Send + Sync>;
23
24#[derive(Debug)]
25pub(crate) struct SignedHeaders {
26 pub key: String,
27 pub signature: String,
28 pub timestamp: String,
29}
30
31pub(crate) trait RequestSigner: Send + Sync {
32 fn sign_request(&self, method: &str, host: &str, path: &str, body: &[u8]) -> SignedHeaders;
33}
34
35#[derive(Debug, thiserror::Error)]
37#[error("{kind}")]
38pub struct SentinelError {
39 #[source]
40 kind: SentinelErrorKind,
41}
42
43#[derive(Debug, thiserror::Error)]
44enum SentinelErrorKind {
45 #[error("HTTP error: {0}")]
46 Http(#[source] BoxError),
47 #[error("API error: {message}")]
48 Api {
49 status: Option<StatusCode>,
50 message: String,
51 },
52 #[error("Failed to build HTTP client: {0}")]
53 ClientBuild(String),
54 #[cfg(feature = "supabase")]
55 #[error("Failed to build HTTP client: {0}")]
56 Supabase(#[source] wowlab_supabase::SupabaseError),
57 #[cfg(feature = "supabase")]
58 #[error("Failed to build HTTP client: {0}")]
59 GameDataCache(#[source] wowlab_engine_adapter_data::CacheError),
60 #[error("Invalid URL: {0}")]
61 InvalidUrl(String),
62 #[error("Serialization error: {0}")]
63 Serialization(#[source] serde_json::Error),
64 #[error("HTTP error: error decoding response body")]
65 Decode(#[source] serde_json::Error),
66}
67
68impl SentinelError {
69 pub(crate) fn client_build(message: impl Into<String>) -> Self {
70 Self::new(SentinelErrorKind::ClientBuild(message.into()))
71 }
72
73 #[cfg(feature = "supabase")]
74 pub(crate) fn supabase(source: wowlab_supabase::SupabaseError) -> Self {
75 Self::new(SentinelErrorKind::Supabase(source))
76 }
77
78 #[cfg(feature = "supabase")]
79 pub(crate) fn game_data_cache(source: wowlab_engine_adapter_data::CacheError) -> Self {
80 Self::new(SentinelErrorKind::GameDataCache(source))
81 }
82
83 fn api(message: impl Into<String>) -> Self {
84 Self::new(SentinelErrorKind::Api {
85 status: None,
86 message: message.into(),
87 })
88 }
89
90 fn api_status(status: StatusCode, message: impl Into<String>) -> Self {
91 Self::new(SentinelErrorKind::Api {
92 status: Some(status),
93 message: message.into(),
94 })
95 }
96
97 fn invalid_url(message: impl Into<String>) -> Self {
98 Self::new(SentinelErrorKind::InvalidUrl(message.into()))
99 }
100
101 fn serialization(error: serde_json::Error) -> Self {
102 Self::new(SentinelErrorKind::Serialization(error))
103 }
104
105 fn decode(error: serde_json::Error) -> Self {
106 Self::new(SentinelErrorKind::Decode(error))
107 }
108
109 fn http(source: BoxError) -> Self {
110 Self::new(SentinelErrorKind::Http(source))
111 }
112
113 fn new(kind: SentinelErrorKind) -> Self {
114 Self { kind }
115 }
116
117 fn status(&self) -> Option<StatusCode> {
118 match &self.kind {
119 SentinelErrorKind::Api { status, .. } => *status,
120 _ => None,
121 }
122 }
123}
124
125#[derive(Clone, Copy, Debug, Eq, PartialEq)]
126pub(crate) enum SentinelUnlinkOutcome {
127 Removed,
128 AlreadyAbsent,
129}
130
131impl From<reqwest::Error> for SentinelError {
132 fn from(error: reqwest::Error) -> Self {
133 Self::http(Box::new(error))
134 }
135}
136
137impl From<serde_json::Error> for SentinelError {
138 fn from(error: serde_json::Error) -> Self {
139 Self::decode(error)
140 }
141}
142
143#[cfg(feature = "supabase")]
144impl From<wowlab_supabase::SupabaseError> for SentinelError {
145 fn from(source: wowlab_supabase::SupabaseError) -> Self {
146 Self::supabase(source)
147 }
148}
149
150#[cfg(feature = "supabase")]
151impl From<wowlab_engine_adapter_data::CacheError> for SentinelError {
152 fn from(source: wowlab_engine_adapter_data::CacheError) -> Self {
153 Self::game_data_cache(source)
154 }
155}
156
157#[derive(Clone)]
158pub(crate) struct SentinelClient {
159 executor: RequestExecutor,
160}
161
162impl SentinelClient {
163 pub(crate) fn new(
164 sentinel_url: String,
165 signer: impl RequestSigner + 'static,
166 ) -> Result<Self, SentinelError> {
167 let http = match reqwest::Client::builder()
168 .timeout(Duration::from_secs(HTTP_REQUEST_TIMEOUT_SECS))
169 .connect_timeout(Duration::from_secs(HTTP_CONNECT_TIMEOUT_SECS))
170 .build()
171 {
172 Ok(http) => http,
173 Err(error) => return Err(SentinelError::client_build(error.to_string())),
174 };
175
176 let sentinel_host = reqwest::Url::parse(&sentinel_url)
177 .map_err(|error| SentinelError::invalid_url(error.to_string()))?
178 .host_str()
179 .ok_or_else(|| SentinelError::invalid_url("URL has no host"))?
180 .to_string();
181
182 Ok(Self {
183 executor: RequestExecutor::new(
184 ExecutorTarget {
185 sentinel_url,
186 sentinel_host,
187 },
188 http,
189 Arc::new(signer),
190 ),
191 })
192 }
193
194 pub(crate) async fn register_node(
195 &self,
196 registration: &NodeRegistrationRequest,
197 ) -> Result<NodeRegistrationResponse, SentinelError> {
198 let body = match serde_json::to_vec(registration) {
199 Ok(body) => body,
200 Err(error) => return Err(SentinelError::serialization(error)),
201 };
202
203 self.executor
204 .execute(NodeRequest::post_json(
205 "/nodes/register",
206 body,
207 MAX_RETRY_ATTEMPTS,
208 ))
209 .await
210 }
211
212 pub(crate) async fn refresh_token(&self) -> Result<Sensitive<String>, SentinelError> {
213 let resp: NodeTokenResponse = self
214 .executor
215 .execute(NodeRequest::post_json(
216 "/nodes/token",
217 Vec::new(),
218 MAX_RETRY_ATTEMPTS,
219 ))
220 .await?;
221
222 Ok(Sensitive::new(resp.into_beacon_token()))
223 }
224
225 pub(crate) async fn unlink(&self) -> Result<SentinelUnlinkOutcome, SentinelError> {
226 let response: Result<NodeUnlinkResponse, SentinelError> = self
227 .executor
228 .execute(NodeRequest::post_json(
229 "/nodes/unlink",
230 Vec::new(),
231 MAX_RETRY_ATTEMPTS,
232 ))
233 .await;
234 let response = match response {
235 Ok(response) => response,
236 Err(error) if error.status() == Some(StatusCode::NOT_FOUND) => {
237 return Ok(SentinelUnlinkOutcome::AlreadyAbsent);
238 }
239 Err(error) => return Err(error),
240 };
241
242 if !response.is_success() {
243 return Err(SentinelError::api("Unlink response reported failure"));
244 }
245
246 Ok(SentinelUnlinkOutcome::Removed)
247 }
248
249 pub(crate) async fn fetch_work_context(
250 &self,
251 job_id: Uuid,
252 hash_hex: &str,
253 claim_token: &str,
254 ) -> Result<NodeWorkContextResponse, SentinelError> {
255 let path = format!("/jobs/{job_id}/work_context?hash={hash_hex}&claim_token={claim_token}");
256
257 self.executor
258 .execute(NodeRequest::get(path, MAX_RETRY_ATTEMPTS))
259 .await
260 }
261
262 pub(crate) async fn complete_batch(
263 &self,
264 completion: &BatchChunkCompletion,
265 ) -> Result<(), SentinelError> {
266 let path = format!("/chunks/complete?job_id={}", completion.job_id);
267 let body = completion.encode_to_vec();
268 let _: NodeChunkCompletionResponse = self
269 .executor
270 .execute(NodeRequest::post_protobuf(path, body, MAX_RETRY_ATTEMPTS))
271 .await?;
272
273 Ok(())
274 }
275}
276
277impl std::fmt::Debug for SentinelClient {
278 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
279 f.debug_struct("SentinelClient")
280 .field("executor", &self.executor)
281 .finish_non_exhaustive()
282 }
283}
284
285#[cfg(test)]
286mod tests;