Skip to main content

Mountain/Vine/
Client.rs

1//! # Vine Client
2//!
3//! Provides a simplified, thread-safe client for communicating with a `Cocoon`
4//! sidecar process via gRPC. It manages a shared pool of connections with
5//! robust error handling, automatic reconnection, health checks, and timeout
6//! management.
7//!
8//! ## Features
9//!
10//! - **Connection Pool**: Thread-safe HashMap of client connections by
11//!   identifier
12//! - **Health Checks**: Validates connection status before RPC calls
13//! - **Automatic Reconnection**: Retries failed connections with exponential
14//!   backoff
15//! - **Request Timeout**: Configurable timeout per RPC call
16//! - **Retry Logic**: Configurable retry attempts for transient failures
17//! - **Message Validation**: Size limits and format checking for all messages
18//! - **Graceful Degradation**: Handles Cocoon unavailability gracefully
19//!
20//! ## Usage Example
21//!
22//! ```rust,no_run
23//! use Vine::Client::{ConnectToSideCar, SendRequest};
24//! use serde_json::json;
25//!
26//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
27//! // Connect to Cocoon
28//! ConnectToSideCar("cocoon-main".to_string(), "127.0.0.1:50052".to_string()).await?;
29//!
30//! // Send request
31//! let result = SendRequest(
32//! 	"cocoon-main",
33//! 	"GetExtensions".to_string(),
34//! 	json!({}),
35//! 	5000, // 5 second timeout
36//! )
37//! .await?;
38//! # Ok(())
39//! # }
40//! ```
41//!
42//! ## Error Handling
43//!
44//! All operations return `Result<T, VineError>` with comprehensive error types:
45//! - ClientNotConnected: Sidecar not in connection pool
46//! - RequestTimeout: RPC call exceeded timeout
47//! - RPCError: gRPC transport or status error
48//! - SerializationError: JSON parsing/serialization failure
49
50use std::{
51	collections::HashMap,
52	sync::Arc,
53	time::{Duration, Instant},
54};
55
56use lazy_static::lazy_static;
57use parking_lot::Mutex;
58use serde_json::{Value, from_slice, to_vec};
59use tokio::time::timeout;
60
61use super::{
62	Error::VineError,
63	Generated::{GenericNotification, GenericRequest, cocoon_service_client::CocoonServiceClient},
64};
65use crate::dev_log;
66
67/// Type alias for the Cocoon gRPC client with Channel transport
68type CocoonClient = CocoonServiceClient<tonic::transport::Channel>;
69
70/// Configuration constants for Vine client behavior
71mod Config {
72	/// Default timeout for RPC calls (5 seconds)
73	pub const DEFAULT_TIMEOUT_MS:u64 = 5000;
74
75	/// Maximum number of retry attempts for failed connections
76	pub const MAX_RETRY_ATTEMPTS:usize = 3;
77
78	/// Base delay between retry attempts (100ms)
79	pub const RETRY_BASE_DELAY_MS:u64 = 100;
80
81	/// Maximum message size for validation (4MB to match tonic default)
82	pub const MAX_MESSAGE_SIZE_BYTES:usize = 4 * 1024 * 1024;
83
84	/// Health check interval (30 seconds)
85	pub const HEALTH_CHECK_INTERVAL_MS:u64 = 30000;
86
87	/// Connection timeout (10 seconds)
88	pub const CONNECTION_TIMEOUT_MS:u64 = 10000;
89}
90
91/// Connection metadata tracking health and last activity
92struct ConnectionMetadata {
93	/// Timestamp of last successful communication
94	LastActivity:Instant,
95	/// Number of consecutive failures since last success
96	FailureCount:usize,
97	/// Whether the connection is currently marked healthy
98	IsHealthy:bool,
99}
100
101lazy_static! {
102	/// Thread-safe pool of Cocoon client connections indexed by identifier
103	static ref SIDECAR_CLIENTS: Arc<Mutex<HashMap<String, CocoonClient>>> = Arc::new(Mutex::new(HashMap::new()));
104
105	/// Thread-safe metadata for connection health tracking
106	static ref CONNECTION_METADATA: Arc<Mutex<HashMap<String, ConnectionMetadata>>> = Arc::new(Mutex::new(HashMap::new()));
107}
108
109/// Establishes a gRPC connection to a sidecar process with retry logic.
110///
111/// This function attempts to connect to a Cocoon sidecar at the specified
112/// address. It implements exponential backoff retry logic for transient
113/// failures and initializes connection metadata for health tracking.
114///
115/// # Parameters
116/// - `SideCarIdentifier`: Unique identifier for this sidecar connection
117/// - `Address`: Network address in format "host:port"
118///
119/// # Returns
120/// - `Ok(())`: Connection successfully established
121/// - `Err(VineError)`: Connection failed after all retry attempts
122///
123/// # Example
124/// ```rust,no_run
125/// # use Vine::Client::ConnectToSideCar;
126/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
127/// ConnectToSideCar("cocoon-main".to_string(), "127.0.0.1:50052".to_string()).await?;
128/// # Ok(())
129/// # }
130/// ```
131pub async fn ConnectToSideCar(SideCarIdentifier:String, Address:String) -> Result<(), VineError> {
132	dev_log!(
133		"grpc",
134		"[VineClient] Connecting to sidecar '{}' at '{}'...",
135		SideCarIdentifier,
136		Address
137	);
138
139	let endpoint = format!("http://{}", Address);
140
141	// Validate endpoint format
142	if endpoint.len() > 256 {
143		return Err(VineError::RPCError(format!("Invalid endpoint address: exceeds maximum length")));
144	}
145
146	// Attempt connection with retry logic
147	let mut last_error = None;
148
149	for attempt in 1..=Config::MAX_RETRY_ATTEMPTS {
150		let result = try_connect_single(&SideCarIdentifier, &endpoint).await;
151
152		if result.is_ok() {
153			// Initialize connection metadata
154			CONNECTION_METADATA.lock().insert(
155				SideCarIdentifier.clone(),
156				ConnectionMetadata { LastActivity:Instant::now(), FailureCount:0, IsHealthy:true },
157			);
158
159			dev_log!("grpc", "[VineClient] Successfully connected to sidecar '{}'", SideCarIdentifier);
160
161			return Ok(result?);
162		}
163
164		// Capture last error
165		last_error = Some(result.unwrap_err());
166
167		// Wait before retry (exponential backoff)
168		if attempt < Config::MAX_RETRY_ATTEMPTS {
169			let delay_ms = Config::RETRY_BASE_DELAY_MS * 2_u64.pow(attempt as u32);
170			tokio::time::sleep(Duration::from_millis(delay_ms)).await;
171		}
172	}
173
174	Err(last_error.unwrap_or_else(|| VineError::RPCError("Connection failed".to_string())))
175}
176
177/// Single connection attempt without retry logic
178async fn try_connect_single(_SideCarIdentifier:&str, endpoint:&str) -> Result<(), VineError> {
179	let endpoint_url = if endpoint.starts_with("http://") || endpoint.starts_with("https://") {
180		endpoint.to_string()
181	} else {
182		format!("http://{}", endpoint)
183	};
184
185	let channel = tonic::transport::Channel::from_shared(endpoint_url)
186		.map_err(|e| VineError::RPCError(format!("Failed to create channel: {}", e)))?
187		.connect()
188		.await
189		.map_err(|e| VineError::RPCError(format!("Failed to connect: {}", e)))?;
190
191	let client = CocoonClient::new(channel);
192
193	let mut clients = SIDECAR_CLIENTS.lock();
194	clients.insert(_SideCarIdentifier.to_string(), client);
195
196	Ok(())
197}
198
199/// Disconnects from a sidecar process and removes it from the connection pool.
200///
201/// This function removes the sidecar from both the connection pool and
202/// connection metadata tracking.
203///
204/// # Parameters
205/// - `SideCarIdentifier`: Unique identifier of the sidecar to disconnect
206///
207/// # Returns
208/// - `Ok(())`: Disconnection successful
209/// - `Err(VineError)`: Sidecar was not connected
210///
211/// # Example
212/// ```rust,no_run
213/// # use Vine::Client::DisconnectFromSideCar;
214/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
215/// DisconnectFromSideCar("cocoon-main".to_string())?;
216/// # Ok(())
217/// # }
218/// ```
219pub fn DisconnectFromSideCar(SideCarIdentifier:String) -> Result<(), VineError> {
220	let mut clients = SIDECAR_CLIENTS.lock();
221
222	if clients.remove(&SideCarIdentifier).is_some() {
223		CONNECTION_METADATA.lock().remove(&SideCarIdentifier);
224
225		dev_log!("grpc", "[VineClient] Disconnected from sidecar '{}'", SideCarIdentifier);
226
227		Ok(())
228	} else {
229		Err(VineError::ClientNotConnected(SideCarIdentifier))
230	}
231}
232
233/// Checks the health status of a connected sidecar.
234///
235/// Health is determined by:
236/// - Connection exists in the pool
237/// - Last activity within health check interval
238/// - Failure count below threshold
239///
240/// # Parameters
241/// - `SideCarIdentifier`: Unique identifier of the sidecar to check
242///
243/// # Returns
244/// - `Ok(true)`: Sidecar is healthy and responsive
245/// - `Ok(false)`: Sidecar exists but may have issues
246/// - `Err(VineError)`: Sidecar not connected
247///
248/// # Example
249/// ```rust,no_run
250/// # use Vine::Client::CheckSideCarHealth;
251/// # fn example() -> Result<bool, Box<dyn std::error::Error>> {
252/// let healthy = CheckSideCarHealth("cocoon-main")?;
253/// # Ok(healthy)
254/// # }
255/// ```
256pub fn CheckSideCarHealth(SideCarIdentifier:&str) -> Result<bool, VineError> {
257	let metadata = CONNECTION_METADATA.lock();
258
259	if let Some(conn) = metadata.get(SideCarIdentifier) {
260		let is_stale = conn.LastActivity.elapsed() > Duration::from_millis(Config::HEALTH_CHECK_INTERVAL_MS);
261		let has_many_failures = conn.FailureCount > Config::MAX_RETRY_ATTEMPTS;
262
263		Ok(conn.IsHealthy && !is_stale && !has_many_failures)
264	} else {
265		Err(VineError::ClientNotConnected(SideCarIdentifier.to_string()))
266	}
267}
268
269/// Records a failure for a sidecar connection.
270///
271/// Increments the failure count and marks the connection as unhealthy.
272///
273/// # Parameters
274/// - `SideCarIdentifier`: Unique identifier of the sidecar that failed
275fn RecordSideCarFailure(SideCarIdentifier:&str) {
276	let mut metadata = CONNECTION_METADATA.lock();
277
278	if let Some(conn) = metadata.get_mut(SideCarIdentifier) {
279		conn.FailureCount += 1;
280		conn.IsHealthy = false;
281	}
282}
283
284/// Updates the last activity timestamp for a sidecar.
285///
286/// Called after successful operations to track liveness.
287///
288/// # Parameters
289/// - `SideCarIdentifier`: Unique identifier of the sidecar
290fn UpdateSideCarActivity(SideCarIdentifier:&str) {
291	let mut metadata = CONNECTION_METADATA.lock();
292
293	if let Some(conn) = metadata.get_mut(SideCarIdentifier) {
294		conn.LastActivity = Instant::now();
295		conn.FailureCount = 0;
296		conn.IsHealthy = true;
297	}
298}
299
300/// Validates message size against maximum allowed.
301///
302/// Helps prevent denial-of-service attacks via overly large messages.
303///
304/// # Parameters
305/// - `data`: Raw byte slice to validate
306///
307/// # Returns
308/// - `Ok(())`: Message size is within limits
309/// - `Err(VineError::SerializationError)`: Message exceeds maximum size
310fn ValidateMessageSize(data:&[u8]) -> Result<(), VineError> {
311	if data.len() > Config::MAX_MESSAGE_SIZE_BYTES {
312		Err(VineError::MessageTooLarge { ActualSize:data.len(), MaxSize:Config::MAX_MESSAGE_SIZE_BYTES })
313	} else {
314		Ok(())
315	}
316}
317
318/// Sends a request to a sidecar and waits for a response.
319///
320/// This is the primary method for request-response communication with sidecars.
321/// It implements timeout handling and automatic connection validation.
322///
323/// # Parameters
324/// - `SideCarIdentifier`: Unique identifier of the target sidecar
325/// - `Method`: RPC method name to call
326/// - `Parameters`: JSON parameters for the RPC call
327/// - `TimeoutMilliseconds`: Maximum time to wait for response (default: 5000ms)
328///
329/// # Returns
330/// - `Ok(Value)`: JSON response from the sidecar
331/// - `Err(VineError)`: Request failed or timed out
332///
333/// # Example
334/// ```rust,no_run
335/// # use Vine::Client::SendRequest;
336/// use serde_json::json;
337/// # async fn example() -> Result<serde_json::Value, Box<dyn std::error::Error>> {
338/// let result =
339/// 	SendRequest("cocoon-main".to_string(), "GetExtensions".to_string(), json!({}), 5000)
340/// 		.await?;
341/// # Ok(result)
342/// # }
343/// ```
344pub async fn SendRequest(
345	SideCarIdentifier:&str,
346	Method:String,
347	Parameters:Value,
348	TimeoutMilliseconds:u64,
349) -> Result<Value, VineError> {
350	// Validate method name format
351	if Method.is_empty() || Method.len() > 128 {
352		return Err(VineError::RPCError(
353			"Method name must be between 1 and 128 characters".to_string(),
354		));
355	}
356
357	let timeout_duration = Duration::from_millis(if TimeoutMilliseconds > 0 {
358		TimeoutMilliseconds
359	} else {
360		Config::DEFAULT_TIMEOUT_MS
361	});
362
363	// Validate message size
364	let parameter_bytes =
365		to_vec(&Parameters).map_err(|e| VineError::RPCError(format!("Failed to serialize parameters: {}", e)))?;
366	ValidateMessageSize(&parameter_bytes)?;
367
368	let client = {
369		let guard = SIDECAR_CLIENTS.lock();
370		guard.get(SideCarIdentifier).cloned()
371	};
372
373	if client.is_none() {
374		return Err(VineError::ClientNotConnected(SideCarIdentifier.to_string()));
375	}
376
377	let mut client = client.unwrap();
378
379	let request_identifier = std::time::SystemTime::now()
380		.duration_since(std::time::UNIX_EPOCH)
381		.unwrap()
382		.as_nanos() as u64;
383	let method_clone = Method.clone();
384	let request = GenericRequest { request_identifier, method:Method, parameter:parameter_bytes };
385
386	let result = timeout(timeout_duration, client.process_mountain_request(request)).await;
387
388	match result {
389		Ok(Ok(response)) => {
390			UpdateSideCarActivity(SideCarIdentifier);
391			dev_log!(
392				"grpc",
393				"[VineClient] Request sent successfully to sidecar '{}': method='{}'",
394				SideCarIdentifier,
395				method_clone
396			);
397
398			// Get the inner response message
399			let inner_response = response.into_inner();
400
401			// Parse response JSON
402			let result_bytes = inner_response.result;
403			let result_value:Value = from_slice(&result_bytes)
404				.map_err(|e| VineError::RPCError(format!("Failed to deserialize response: {}", e)))?;
405
406			// Check for RPC errors in response
407			if let Some(error_data) = inner_response.error {
408				return Err(VineError::RPCError(format!(
409					"RPC error from sidecar: code={}, message={}",
410					error_data.code, error_data.message
411				)));
412			}
413
414			Ok(result_value)
415		},
416		Ok(Err(status)) => {
417			RecordSideCarFailure(SideCarIdentifier);
418			return Err(VineError::RPCError(format!("gRPC error: {}", status)));
419		},
420		Err(_) => {
421			RecordSideCarFailure(SideCarIdentifier);
422			Err(VineError::RequestTimeout {
423				SideCarIdentifier:SideCarIdentifier.to_string(),
424				MethodName:method_clone,
425				TimeoutMilliseconds:timeout_duration.as_millis() as u64,
426			})
427		},
428	}
429}
430
431/// Sends a notification to a sidecar without waiting for a response.
432///
433/// Note: This does not include a timeout parameter (unlike `SendRequest`).
434/// Notifications are sent as fire-and-forget messages.
435///
436/// ```rust,no_run
437/// # use Vine::Client::SendNotification;
438/// use serde_json::json;
439/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
440/// SendNotification(
441///     "cocoon-main".to_string(),
442///     "UpdateTheme".to_string(),
443///     json!({"theme": "dark"}),
444/// ).await?;
445/// # Ok(())
446/// # }
447/// ```
448pub async fn SendNotification(SideCarIdentifier:String, Method:String, Parameters:Value) -> Result<(), VineError> {
449	// Validate method name format
450	if Method.is_empty() || Method.len() > 128 {
451		return Err(VineError::RPCError(
452			"Method name must be between 1 and 128 characters".to_string(),
453		));
454	}
455
456	let parameter_bytes = to_vec(&Parameters)?;
457	ValidateMessageSize(&parameter_bytes)?;
458
459	let mut client = {
460		let guard = SIDECAR_CLIENTS.lock();
461		guard.get(&SideCarIdentifier).cloned()
462	};
463
464	if let Some(ref mut client) = client {
465		let request = GenericNotification { method:Method, parameter:parameter_bytes };
466
467		match client.send_mountain_notification(request).await {
468			Ok(_) => {
469				UpdateSideCarActivity(&SideCarIdentifier);
470				dev_log!(
471					"grpc",
472					"[VineClient] Notification sent successfully to sidecar '{}'",
473					SideCarIdentifier
474				);
475				Ok(())
476			},
477			Err(status) => {
478				RecordSideCarFailure(&SideCarIdentifier);
479				dev_log!(
480					"grpc",
481					"error: [VineClient] Failed to send notification to sidecar '{}': {}",
482					SideCarIdentifier,
483					status
484				);
485				Err(VineError::from(status))
486			},
487		}
488	} else {
489		Err(VineError::ClientNotConnected(SideCarIdentifier))
490	}
491}