1use crate::{
2 MemoryCandidate, MemoryError, MemoryKey, MemoryLimits, MemoryRecord, MemoryScan, MemoryStore,
3 server::protocol,
4};
5use protocol::{
6 DeleteRequest, ErrorResponse, ExportRequest, ExportResponse, ListResponse, PutRequest,
7 PutResponse, ReadRequest, ReadResponse, RemoteErrorCode, RemoteRole, ScanRequest, ScanResponse,
8 SessionResponse, SyncReport, SyncRequest,
9};
10use reqwest::{Client, Response, StatusCode, Url};
11use serde::{Serialize, de::DeserializeOwned};
12use std::{collections::HashSet, fmt, sync::Arc, time::Duration};
13use thiserror::Error;
14use tokio::time::sleep;
15use zeroize::{Zeroize, Zeroizing};
16
17const ATTEMPTS: usize = 3;
18const CONNECT_TIMEOUT: Duration = Duration::from_millis(750);
19const REQUEST_TIMEOUT: Duration = Duration::from_secs(2);
20const MAX_RESPONSE_BYTES: usize = 8 * 1024 * 1024;
21const RETRY_BACKOFFS: [Option<Duration>; ATTEMPTS] = [
22 Some(Duration::from_millis(100)),
23 Some(Duration::from_millis(250)),
24 None,
25];
26
27pub struct RemoteToken(Zeroizing<String>);
29
30impl RemoteToken {
31 pub fn new(token: String) -> Result<Self, RemoteClientError> {
33 if token.trim().is_empty() {
34 return Err(RemoteClientError::EmptyToken);
35 }
36 Ok(Self(Zeroizing::new(token)))
37 }
38
39 fn expose(&self) -> &str {
40 self.0.as_str()
41 }
42}
43
44impl fmt::Debug for RemoteToken {
45 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
46 formatter.write_str("RemoteToken([REDACTED])")
47 }
48}
49
50impl Drop for RemoteToken {
51 fn drop(&mut self) {
52 self.zeroize();
53 }
54}
55
56impl Zeroize for RemoteToken {
57 fn zeroize(&mut self) {
58 self.0.zeroize();
59 }
60}
61
62#[derive(Debug, Error)]
64pub enum RemoteClientError {
65 #[error("remote memory endpoint is invalid")]
67 InvalidEndpoint,
68 #[error("remote memory namespace is invalid")]
70 InvalidNamespace,
71 #[error("remote memory token is empty")]
73 EmptyToken,
74 #[error("remote memory request could not reach the server")]
76 Transport,
77 #[error("remote memory server rejected authentication")]
79 Unauthorized,
80 #[error("remote memory credential is read-only")]
82 ReadOnly,
83 #[error("remote memory namespace does not match the credential")]
85 NamespaceMismatch,
86 #[error("remote memory protocol is incompatible")]
88 IncompatibleProtocol,
89 #[error("remote memory server rejected the operation: {code:?}")]
91 Rejected {
92 code: RemoteErrorCode,
94 },
95 #[error("remote memory server returned an invalid response")]
97 InvalidResponse,
98 #[error("remote memory service is unavailable")]
100 Unavailable,
101}
102
103#[derive(Clone)]
105pub struct RemoteMemoryClient {
106 inner: Arc<RemoteClientInner>,
107}
108
109impl fmt::Debug for RemoteMemoryClient {
110 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
111 formatter
112 .debug_struct("RemoteMemoryClient")
113 .field("endpoint", &self.inner.endpoint)
114 .field("namespace", &self.inner.namespace)
115 .field("token", &"[REDACTED]")
116 .finish()
117 }
118}
119
120struct RemoteClientInner {
121 endpoint: Url,
122 namespace: String,
123 token: RemoteToken,
124 client: Client,
125 role: tokio::sync::OnceCell<RemoteRole>,
126}
127
128impl RemoteMemoryClient {
129 pub fn new(
131 endpoint: &str,
132 namespace: String,
133 token: RemoteToken,
134 ) -> Result<Self, RemoteClientError> {
135 let _ = rustls::crypto::ring::default_provider().install_default();
136 let mut endpoint = Url::parse(endpoint).map_err(|_| RemoteClientError::InvalidEndpoint)?;
137 if !matches!(endpoint.scheme(), "http" | "https")
138 || !endpoint.username().is_empty()
139 || endpoint.password().is_some()
140 {
141 return Err(RemoteClientError::InvalidEndpoint);
142 }
143 if !protocol::is_valid_namespace(&namespace) {
144 return Err(RemoteClientError::InvalidNamespace);
145 }
146 if !endpoint.path().ends_with('/') {
147 let mut path = endpoint.path().to_owned();
148 path.push('/');
149 endpoint.set_path(&path);
150 }
151 let client = Client::builder()
152 .connect_timeout(CONNECT_TIMEOUT)
153 .timeout(REQUEST_TIMEOUT)
154 .build()
155 .map_err(|_| RemoteClientError::InvalidEndpoint)?;
156 Ok(Self {
157 inner: Arc::new(RemoteClientInner {
158 endpoint,
159 namespace,
160 token,
161 client,
162 role: tokio::sync::OnceCell::new(),
163 }),
164 })
165 }
166
167 pub fn namespace(&self) -> &str {
169 &self.inner.namespace
170 }
171
172 pub async fn session(&self) -> Result<RemoteRole, RemoteClientError> {
174 self.inner
175 .role
176 .get_or_try_init(|| async {
177 let response: SessionResponse =
178 self.get(protocol::SESSION_PATH, Replay::Safe).await?;
179 if response.protocol_version != crate::VERSION {
180 return Err(RemoteClientError::IncompatibleProtocol);
181 }
182 if response.namespace != self.inner.namespace {
183 return Err(RemoteClientError::NamespaceMismatch);
184 }
185 Ok(response.role)
186 })
187 .await
188 .copied()
189 }
190
191 async fn scan(
192 &self,
193 query: &str,
194 limit: usize,
195 ) -> Result<Vec<MemoryCandidate>, RemoteClientError> {
196 let limit = limit.min(MemoryLimits::PRODUCTION.scan_results);
197 if limit == 0 {
198 return Ok(Vec::new());
199 }
200 let response: ScanResponse = self
201 .post(
202 protocol::SCAN_PATH,
203 &ScanRequest {
204 query: query.to_owned(),
205 limit,
206 },
207 Replay::ConnectOnly,
208 )
209 .await?;
210 if response.candidates.len() > limit {
211 return Err(RemoteClientError::InvalidResponse);
212 }
213 let mut seen = HashSet::new();
214 let mut candidates = Vec::new();
215 let mut previous_score = None;
216 for candidate in response.candidates {
217 if !self.valid_candidate(&candidate) {
218 continue;
219 }
220 if previous_score.is_some_and(|score| candidate.score > score) {
221 return Err(RemoteClientError::InvalidResponse);
222 }
223 previous_score = Some(candidate.score);
224 let Some(namespace) = candidate.key.namespace.clone() else {
225 continue;
226 };
227 if !seen.insert((namespace, candidate.key.id)) {
228 return Err(RemoteClientError::InvalidResponse);
229 }
230 candidates.push(candidate);
231 }
232 Ok(candidates)
233 }
234
235 async fn read(
236 &self,
237 ids: &[i64],
238 keys: &[MemoryKey],
239 ) -> Result<Vec<MemoryRecord>, RemoteClientError> {
240 let keys = keys
241 .iter()
242 .filter(|key| Self::valid_key(key) && key.namespace.is_some())
243 .cloned()
244 .collect::<Vec<_>>();
245 let ids = ids.iter().copied().filter(|id| *id > 0).collect::<Vec<_>>();
246 if keys.is_empty() && ids.is_empty() {
247 return Ok(Vec::new());
248 }
249 let requested = keys.iter().cloned().collect::<HashSet<_>>();
250 let requested_ids = ids.iter().copied().collect::<HashSet<_>>();
251 let response: ReadResponse = self
252 .post(
253 protocol::READ_PATH,
254 &ReadRequest { ids, keys },
255 Replay::ConnectOnly,
256 )
257 .await?;
258 let requested_records = requested
259 .iter()
260 .filter_map(|key| key.namespace.clone().map(|namespace| (namespace, key.id)))
261 .chain(
262 requested_ids
263 .iter()
264 .map(|id| (self.namespace().to_owned(), *id)),
265 )
266 .collect::<HashSet<_>>();
267 if response.memories.len() > requested_records.len() {
268 return Err(RemoteClientError::InvalidResponse);
269 }
270
271 let mut seen = HashSet::new();
272 let mut memories = Vec::new();
273 for memory in response.memories {
274 if !(requested.contains(&memory.key)
275 || (memory.key.namespace.as_deref() == Some(self.namespace())
276 && requested_ids.contains(&memory.key.id)))
277 || !Self::valid_record(&memory)
278 {
279 continue;
280 }
281 let logical_key = (memory.key.namespace.clone().unwrap(), memory.key.id);
282 if !seen.insert(logical_key) {
283 return Err(RemoteClientError::InvalidResponse);
284 }
285 memories.push(memory);
286 }
287 Ok(memories)
288 }
289
290 async fn list(&self) -> Result<Vec<MemoryRecord>, RemoteClientError> {
291 let response: ListResponse = self.post(protocol::LIST_PATH, &(), Replay::Safe).await?;
292 if response.memories.len() > MemoryLimits::PRODUCTION.records {
293 return Err(RemoteClientError::InvalidResponse);
294 }
295 let mut seen = HashSet::new();
296 let mut memories = Vec::new();
297 for memory in response.memories {
298 if !Self::valid_record(&memory) {
299 continue;
300 }
301 let Some(namespace) = memory.key.namespace.clone() else {
302 continue;
303 };
304 let logical_key = (namespace, memory.key.id);
305 if !seen.insert(logical_key) {
306 return Err(RemoteClientError::InvalidResponse);
307 }
308 memories.push(memory);
309 }
310 Ok(memories)
311 }
312
313 async fn put(
314 &self,
315 content: &str,
316 replacement: Option<&MemoryKey>,
317 ) -> Result<MemoryRecord, RemoteClientError> {
318 let response: PutResponse = self
319 .post(
320 protocol::PUT_PATH,
321 &PutRequest {
322 content: content.to_owned(),
323 replacement: replacement.cloned(),
324 },
325 Replay::ConnectOnly,
326 )
327 .await?;
328 if !Self::valid_record(&response.memory)
329 || response.memory.key.namespace.as_deref() != Some(self.namespace())
330 || response.memory.content != content
331 || match replacement {
332 Some(replacement) => {
333 response.memory.key.id != replacement.id
334 || replacement
335 .version
336 .checked_add(1)
337 .is_none_or(|version| response.memory.key.version != version)
338 }
339 None => response.memory.key.version != 1,
340 }
341 {
342 return Err(RemoteClientError::InvalidResponse);
343 }
344 Ok(response.memory)
345 }
346
347 async fn delete(&self, key: &MemoryKey) -> Result<(), RemoteClientError> {
348 if key.namespace.as_deref() != Some(self.namespace()) {
349 return Err(RemoteClientError::NamespaceMismatch);
350 }
351 self.post::<_, serde_json::Value>(
352 protocol::DELETE_PATH,
353 &DeleteRequest { key: key.clone() },
354 Replay::Safe,
355 )
356 .await?;
357 Ok(())
358 }
359
360 async fn sync(&self, memories: &[MemoryRecord]) -> Result<SyncReport, RemoteClientError> {
361 let report: SyncReport = self
362 .post(
363 protocol::SYNC_PATH,
364 &SyncRequest {
365 memories: memories.to_vec(),
366 },
367 Replay::Safe,
368 )
369 .await?;
370 let applied = report
371 .inserted
372 .checked_add(report.replaced)
373 .and_then(|count| count.checked_add(report.unchanged));
374 if applied != Some(memories.len()) || report.deleted > MemoryLimits::PRODUCTION.records {
375 return Err(RemoteClientError::InvalidResponse);
376 }
377 Ok(report)
378 }
379
380 fn validate_export_page(
381 namespaces: Option<&[String]>,
382 cursor: Option<&protocol::ExportCursor>,
383 accumulated_records: usize,
384 accumulated_content_bytes: usize,
385 response: &ExportResponse,
386 ) -> Result<usize, RemoteClientError> {
387 if response.memories.len() > protocol::MAX_EXPORT_PAGE_RECORDS
388 || accumulated_records
389 .checked_add(response.memories.len())
390 .is_none_or(|count| count > MemoryLimits::PRODUCTION.records)
391 {
392 return Err(RemoteClientError::InvalidResponse);
393 }
394
395 let mut previous = cursor.cloned();
396 let mut page_content_bytes = 0usize;
397 for memory in &response.memories {
398 let Some(namespace) = memory.key.namespace.as_deref() else {
399 return Err(RemoteClientError::InvalidResponse);
400 };
401 let selected = namespaces
402 .is_none_or(|selected| selected.iter().any(|candidate| candidate == namespace));
403 let ordered = previous.as_ref().is_none_or(|previous| {
404 (namespace, memory.key.id) > (previous.namespace.as_str(), previous.id)
405 });
406 if !Self::valid_record(memory) || !selected || !ordered {
407 return Err(RemoteClientError::InvalidResponse);
408 }
409
410 page_content_bytes = page_content_bytes
411 .checked_add(memory.content.len())
412 .ok_or(RemoteClientError::InvalidResponse)?;
413 if accumulated_content_bytes
414 .checked_add(page_content_bytes)
415 .is_none_or(|bytes| bytes > MemoryLimits::PRODUCTION.total_content_bytes)
416 {
417 return Err(RemoteClientError::InvalidResponse);
418 }
419 previous = Some(protocol::ExportCursor {
420 namespace: namespace.to_owned(),
421 id: memory.key.id,
422 });
423 }
424
425 if let Some(next_cursor) = &response.next_cursor {
426 let exact_last_key = response.memories.last().is_some_and(|memory| {
427 memory.key.namespace.as_deref() == Some(next_cursor.namespace.as_str())
428 && memory.key.id == next_cursor.id
429 });
430 if !exact_last_key {
431 return Err(RemoteClientError::InvalidResponse);
432 }
433 }
434 Ok(page_content_bytes)
435 }
436
437 fn valid_key(key: &MemoryKey) -> bool {
438 key.namespace
439 .as_deref()
440 .is_none_or(protocol::is_valid_namespace)
441 && key.id > 0
442 && key.version > 0
443 }
444
445 fn valid_candidate(&self, candidate: &MemoryCandidate) -> bool {
446 Self::valid_key(&candidate.key)
447 && candidate.key.namespace.is_some()
448 && candidate.preview.len() <= 64
449 && candidate.score.is_finite()
450 && candidate.score >= 0.0
451 && !crate::secrets::contains_likely_secret(&candidate.preview)
452 }
453
454 fn valid_record(memory: &MemoryRecord) -> bool {
455 Self::valid_key(&memory.key)
456 && memory.key.namespace.is_some()
457 && !memory.content.trim().is_empty()
458 && memory.content.len() <= MemoryLimits::PRODUCTION.content_bytes
459 && memory.created_at_ms >= 0
460 && memory.updated_at_ms >= memory.created_at_ms
461 && !crate::secrets::contains_likely_secret(&memory.content)
462 }
463
464 async fn get<Response>(&self, path: &str, replay: Replay) -> Result<Response, RemoteClientError>
465 where
466 Response: DeserializeOwned,
467 {
468 self.send(path, None::<&()>, replay).await
469 }
470
471 async fn post<Request, Response>(
472 &self,
473 path: &str,
474 body: &Request,
475 replay: Replay,
476 ) -> Result<Response, RemoteClientError>
477 where
478 Request: Serialize + ?Sized,
479 Response: DeserializeOwned,
480 {
481 self.send(path, Some(body), replay).await
482 }
483
484 async fn send<Request, Response>(
485 &self,
486 path: &str,
487 body: Option<&Request>,
488 replay: Replay,
489 ) -> Result<Response, RemoteClientError>
490 where
491 Request: Serialize + ?Sized,
492 Response: DeserializeOwned,
493 {
494 let url = self
495 .inner
496 .endpoint
497 .join(path)
498 .map_err(|_| RemoteClientError::InvalidEndpoint)?;
499 for backoff in RETRY_BACKOFFS {
500 let request = match body {
501 Some(body) => self.inner.client.post(url.clone()).json(body),
502 None => self.inner.client.get(url.clone()),
503 }
504 .bearer_auth(self.inner.token.expose())
506 .header(protocol::NAMESPACE_HEADER, &self.inner.namespace);
507
508 match request.send().await {
509 Ok(response) if response.status().is_success() => {
510 return decode_response(response).await;
511 }
512 Ok(response) if replay == Replay::Safe && retryable_status(response.status()) => {
513 let Some(backoff) = backoff else {
514 return Err(response_error(response).await);
515 };
516 sleep(response_retry_delay(response.headers(), backoff)).await;
517 }
518 Ok(response) => return Err(response_error(response).await),
519 Err(error) if error.is_connect() => {
520 let Some(backoff) = backoff else {
521 return Err(RemoteClientError::Transport);
522 };
523 sleep(backoff).await;
524 }
525 Err(_) => return Err(RemoteClientError::Transport),
526 }
527 }
528 Err(RemoteClientError::Unavailable)
529 }
530}
531
532impl MemoryStore for RemoteMemoryClient {
533 fn scan(
534 &self,
535 query: &str,
536 limit: usize,
537 ) -> impl std::future::Future<Output = Result<MemoryScan, MemoryError>> + Send {
538 async move {
539 let candidates = RemoteMemoryClient::scan(self, query, limit).await?;
540 Ok(MemoryScan {
541 abstained: candidates.is_empty(),
542 candidates,
543 })
544 }
545 }
546 fn read(
547 &self,
548 ids: &[i64],
549 keys: &[MemoryKey],
550 ) -> impl std::future::Future<Output = Result<Vec<MemoryRecord>, MemoryError>> + Send {
551 async move { Ok(RemoteMemoryClient::read(self, ids, keys).await?) }
552 }
553 fn list(
554 &self,
555 ) -> impl std::future::Future<Output = Result<Vec<MemoryRecord>, MemoryError>> + Send {
556 async move { Ok(RemoteMemoryClient::list(self).await?) }
557 }
558 fn put(
559 &self,
560 content: &str,
561 replacement: Option<MemoryKey>,
562 ) -> impl std::future::Future<Output = Result<MemoryRecord, MemoryError>> + Send {
563 async move {
564 if content.trim().is_empty() {
565 return Err(MemoryError::EmptyContent);
566 }
567 Ok(RemoteMemoryClient::put(self, content, replacement.as_ref()).await?)
568 }
569 }
570 fn delete(
571 &self,
572 key: MemoryKey,
573 ) -> impl std::future::Future<Output = Result<(), MemoryError>> + Send {
574 async move { Ok(RemoteMemoryClient::delete(self, &key).await?) }
575 }
576 fn sync(
577 &self,
578 memories: &[MemoryRecord],
579 ) -> impl std::future::Future<Output = Result<SyncReport, MemoryError>> + Send {
580 async move { Ok(RemoteMemoryClient::sync(self, memories).await?) }
581 }
582 fn export_page(
583 &self,
584 namespaces: Option<&[String]>,
585 cursor: Option<&protocol::ExportCursor>,
586 limit: usize,
587 ) -> impl std::future::Future<
588 Output = Result<(Vec<MemoryRecord>, Option<protocol::ExportCursor>), MemoryError>,
589 > + Send {
590 let namespaces = namespaces.map(<[String]>::to_vec);
591 let cursor = cursor.cloned();
592 async move {
593 let limit = limit.clamp(1, protocol::MAX_EXPORT_PAGE_RECORDS);
594 let response: ExportResponse = self
595 .post(
596 protocol::EXPORT_PATH,
597 &ExportRequest {
598 namespaces: namespaces.clone(),
599 cursor: cursor.clone(),
600 limit,
601 },
602 Replay::Safe,
603 )
604 .await?;
605 if response.memories.len() > limit {
606 return Err(RemoteClientError::InvalidResponse.into());
607 }
608 Self::validate_export_page(namespaces.as_deref(), cursor.as_ref(), 0, 0, &response)?;
609 Ok((response.memories, response.next_cursor))
610 }
611 }
612}
613
614#[derive(Clone, Copy, Eq, PartialEq)]
615enum Replay {
616 Safe,
617 ConnectOnly,
618}
619
620fn retryable_status(status: StatusCode) -> bool {
621 matches!(
622 status,
623 StatusCode::TOO_MANY_REQUESTS
624 | StatusCode::BAD_GATEWAY
625 | StatusCode::SERVICE_UNAVAILABLE
626 | StatusCode::GATEWAY_TIMEOUT
627 )
628}
629
630fn response_retry_delay(headers: &reqwest::header::HeaderMap, fallback: Duration) -> Duration {
631 headers
632 .get(reqwest::header::RETRY_AFTER)
633 .and_then(|value| value.to_str().ok())
634 .and_then(|value| value.parse::<u64>().ok())
635 .map(|seconds| Duration::from_secs(seconds.min(2)))
636 .unwrap_or(fallback)
637}
638
639async fn response_error(response: Response) -> RemoteClientError {
640 let status = response.status();
641 let code = decode_response::<ErrorResponse>(response)
642 .await
643 .ok()
644 .map(|response| response.code);
645 match (status, code) {
646 (StatusCode::UNAUTHORIZED, _) | (_, Some(RemoteErrorCode::Unauthorized)) => {
647 RemoteClientError::Unauthorized
648 }
649 (StatusCode::FORBIDDEN, Some(RemoteErrorCode::NamespaceMismatch)) => {
650 RemoteClientError::NamespaceMismatch
651 }
652 (StatusCode::FORBIDDEN, _) | (_, Some(RemoteErrorCode::Forbidden)) => {
653 RemoteClientError::ReadOnly
654 }
655 (_, Some(RemoteErrorCode::UnsupportedProtocol)) => RemoteClientError::IncompatibleProtocol,
656 (_, Some(code)) => RemoteClientError::Rejected { code },
657 (StatusCode::NOT_FOUND, None) => RemoteClientError::IncompatibleProtocol,
658 (_, None) if status.is_server_error() || status == StatusCode::TOO_MANY_REQUESTS => {
659 RemoteClientError::Unavailable
660 }
661 _ => RemoteClientError::InvalidResponse,
662 }
663}
664
665async fn decode_response<Decoded>(mut response: Response) -> Result<Decoded, RemoteClientError>
666where
667 Decoded: DeserializeOwned,
668{
669 let mut bytes = Vec::new();
670 while let Some(chunk) = response
671 .chunk()
672 .await
673 .map_err(|_| RemoteClientError::InvalidResponse)?
674 {
675 if bytes.len().saturating_add(chunk.len()) > MAX_RESPONSE_BYTES {
676 return Err(RemoteClientError::InvalidResponse);
677 }
678 bytes.extend_from_slice(&chunk);
679 }
680 serde_json::from_slice(&bytes).map_err(|_| RemoteClientError::InvalidResponse)
681}
682
683#[cfg(test)]
684mod tests {
685 use super::*;
686 use protocol::ExportCursor;
687
688 fn memory(namespace: &str, id: i64, content: &str) -> MemoryRecord {
689 MemoryRecord {
690 key: MemoryKey::remote(namespace.to_owned(), id, 1),
691 content: content.to_owned(),
692 created_at_ms: 1,
693 updated_at_ms: 1,
694 last_scanned_at_ms: None,
695 scan_count: 0,
696 last_used_at_ms: None,
697 use_count: 0,
698 probation_until_ms: None,
699 }
700 }
701
702 fn response(memories: Vec<MemoryRecord>, next: Option<(&str, i64)>) -> ExportResponse {
703 ExportResponse {
704 memories,
705 next_cursor: next.map(|(namespace, id)| ExportCursor {
706 namespace: namespace.to_owned(),
707 id,
708 }),
709 }
710 }
711
712 fn invalid(result: Result<usize, RemoteClientError>) -> bool {
713 matches!(result, Err(RemoteClientError::InvalidResponse))
714 }
715
716 #[tokio::test]
717 async fn delete_rejects_a_key_without_the_authenticated_namespace() {
718 let token = RemoteToken::new("test-token".to_owned()).unwrap();
719 let client =
720 RemoteMemoryClient::new("http://127.0.0.1:1/", "alice".to_owned(), token).unwrap();
721
722 assert!(matches!(
723 client.delete(&MemoryKey::local(1, 1)).await,
724 Err(RemoteClientError::NamespaceMismatch)
725 ));
726 }
727
728 #[test]
729 fn client_failures_preserve_memory_error_semantics() {
730 assert!(MemoryError::from(RemoteClientError::Transport).is_retryable());
731 assert!(MemoryError::from(RemoteClientError::Unavailable).is_retryable());
732 assert!(
733 MemoryError::from(RemoteClientError::Rejected {
734 code: RemoteErrorCode::Unavailable,
735 })
736 .is_retryable()
737 );
738 assert!(!MemoryError::from(RemoteClientError::InvalidResponse).is_retryable());
739 assert!(matches!(
740 MemoryError::from(RemoteClientError::Rejected {
741 code: RemoteErrorCode::Conflict,
742 }),
743 MemoryError::Conflict
744 ));
745 }
746
747 #[test]
748 fn export_page_enforces_namespace_and_exact_cursor() {
749 let selected = ["alpha".to_owned()];
750
751 assert_eq!(
752 RemoteMemoryClient::validate_export_page(
753 Some(&selected),
754 None,
755 0,
756 0,
757 &response(vec![memory("alpha", 1, "one")], Some(("alpha", 1))),
758 )
759 .expect("valid page"),
760 3
761 );
762 assert!(invalid(RemoteMemoryClient::validate_export_page(
763 Some(&selected),
764 None,
765 0,
766 0,
767 &response(vec![memory("beta", 1, "one")], None),
768 )));
769 assert!(invalid(RemoteMemoryClient::validate_export_page(
770 Some(&selected),
771 None,
772 0,
773 0,
774 &response(vec![memory("alpha", 1, "one")], Some(("alpha", 2))),
775 )));
776 }
777
778 #[test]
779 fn export_page_rejects_duplicate_and_out_of_order_keys() {
780 assert!(invalid(RemoteMemoryClient::validate_export_page(
781 None,
782 None,
783 0,
784 0,
785 &response(
786 vec![memory("alpha", 1, "one"), memory("alpha", 1, "two")],
787 None,
788 ),
789 )));
790 assert!(invalid(RemoteMemoryClient::validate_export_page(
791 None,
792 Some(&ExportCursor {
793 namespace: "beta".to_owned(),
794 id: 2,
795 }),
796 2,
797 6,
798 &response(vec![memory("alpha", 1, "one")], Some(("alpha", 1))),
799 )));
800 }
801
802 #[test]
803 fn export_page_rejects_multi_cursor_cycles() {
804 let first = response(vec![memory("alpha", 1, "one")], Some(("alpha", 1)));
805 let second = response(vec![memory("beta", 1, "two")], Some(("beta", 1)));
806 let cycle = response(vec![memory("alpha", 1, "one")], Some(("alpha", 1)));
807
808 assert_eq!(
809 RemoteMemoryClient::validate_export_page(None, None, 0, 0, &first)
810 .expect("valid first page"),
811 3
812 );
813 assert_eq!(
814 RemoteMemoryClient::validate_export_page(
815 None,
816 first.next_cursor.as_ref(),
817 1,
818 3,
819 &second,
820 )
821 .expect("valid second page"),
822 3
823 );
824 assert!(invalid(RemoteMemoryClient::validate_export_page(
825 None,
826 second.next_cursor.as_ref(),
827 2,
828 6,
829 &cycle,
830 )));
831 }
832
833 #[test]
834 fn export_page_rejects_aggregate_limits_before_accumulation() {
835 let page = response(vec![memory("alpha", 1, "x")], None);
836
837 assert!(invalid(RemoteMemoryClient::validate_export_page(
838 None,
839 None,
840 MemoryLimits::PRODUCTION.records,
841 0,
842 &page,
843 )));
844 assert!(invalid(RemoteMemoryClient::validate_export_page(
845 None,
846 None,
847 0,
848 MemoryLimits::PRODUCTION.total_content_bytes,
849 &page,
850 )));
851 }
852}