Skip to main content

fusillade_core/request/
transitions.rs

1//! State transitions for batch requests using the typestate pattern.
2//!
3//! This module implements state transitions for HTTP batch requests using Rust's
4//! type system to enforce valid state transitions at compile time. Each request
5//! state is represented as a distinct type parameter on `Request<State>`.
6//!
7//! # Typestate Pattern
8//!
9//! The typestate pattern leverages Rust's type system to make invalid states
10//! unrepresentable. A `Request<Pending>` can only call methods available for
11//! pending requests, and transitions return different types:
12//!
13//! ```text
14//! Request<Pending> ──claim()──> Request<Claimed> ──process()──> Request<Processing>
15//!       │                             │                               │
16//!       │                             │                               └──complete()──> Request<Completed>
17//!       │                             │                               └──complete()──> Request<Failed>
18//!       └──cancel()──> Request<Canceled>                              └──cancel()────> Request<Canceled>
19//!                              │
20//!                              └──unclaim()─> Request<Pending>
21//!
22//! Request<Failed> ──retry()──> Request<Pending>  (if retries remain)
23//!                 ──retry()──> None              (if max retries reached)
24//! ```
25//!
26//! # State Lifecycle
27//!
28//! ## 1. Pending → Claimed
29//!
30//! A daemon claims a pending request for processing:
31//! - Records which daemon claimed it
32//! - Sets claimed_at timestamp
33//! - Preserves retry attempt count
34//!
35//! ## 2. Claimed → Processing
36//!
37//! The daemon starts executing the HTTP request:
38//! - Spawns an async task to make the HTTP call
39//! - Creates a channel to receive the result
40//! - Provides an abort handle for cancellation
41//!
42//! ## 3. Processing → Completed or Failed
43//!
44//! The HTTP request completes:
45//! - **Success**: Transitions to `Completed` with response body
46//! - **Failure**: Transitions to `Failed` with error message
47//! - **Retriable**: HTTP succeeded but status code indicates retry (e.g., 429, 500)
48//!
49//! ## 4. Failed → Pending (Retry)
50//!
51//! Failed requests can be retried with exponential backoff:
52//! - Increments retry_attempt counter
53//! - Calculates backoff delay: `backoff_ms * (factor ^ attempt)`
54//! - Sets not_before timestamp to delay retry
55//! - Returns `None` if max retries exceeded
56//!
57//! ## 5. Any State → Canceled
58//!
59//! Requests can be canceled from most states:
60//! - `Pending`: Simply marks as canceled
61//! - `Claimed`: Releases claim and cancels
62//! - `Processing`: Aborts the in-flight HTTP request
63//!
64//! # Retry Configuration
65//!
66//! Exponential backoff and retry limits are configured via [`RetryConfig`]:
67//!
68//! ```rust
69//! # use fusillade_core::request::transitions::RetryConfig;
70//! let config = RetryConfig {
71//!     max_retries: Some(1000),
72//!     stop_before_deadline_ms: Some(900_000),
73//!     backoff_ms: 1000,         // Start with 1 second
74//!     backoff_factor: 2,        // Double each time (1s, 2s, 4s)
75//!     max_backoff_ms: 60000,    // Cap at 60 seconds
76//! };
77//! ```
78//!
79//! # Example Workflow
80//!
81//! ```ignore
82//! // Daemon claims a pending request
83//! let pending: Request<Pending> = storage.next_pending().await?;
84//! let claimed = pending.claim(daemon_id, &storage).await?;
85//!
86//! // Start processing
87//! let processing = claimed.process(http_client, &storage).await?;
88//!
89//! // Wait for completion
90//! let result = processing.complete(&storage, |resp| resp.status >= 500).await?;
91//!
92//! match result {
93//!     Ok(completed) => println!("Success: {}", completed.state.response_status),
94//!     Err(failed) => {
95//!         // Attempt retry with backoff
96//!         if let Some(retrying) = failed.retry(retry_attempt, config, &storage).await? {
97//!             println!("Retrying request...");
98//!         } else {
99//!             println!("Max retries exceeded");
100//!         }
101//!     }
102//! }
103//! ```
104
105use std::sync::Arc;
106
107use tokio::sync::Mutex;
108use tracing::Instrument;
109
110use crate::{FusilladeError, error::Result, manager::Storage};
111
112use super::types::{
113    Canceled, Claimed, Completed, DaemonId, Failed, FailureReason, HttpResponse, Pending,
114    Processing, Request, RequestCompletionResult,
115};
116
117/// Reason for cancelling a request.
118#[derive(Debug, Clone, Copy)]
119pub enum CancellationReason {
120    /// User-initiated cancellation (should persist Canceled state).
121    User,
122    /// Daemon shutdown (abort HTTP but don't persist state change).
123    Shutdown,
124}
125
126impl Request<Pending> {
127    pub async fn claim<S: Storage + ?Sized>(
128        self,
129        daemon_id: DaemonId,
130        storage: &S,
131    ) -> Result<Request<Claimed>> {
132        let request = Request {
133            data: self.data,
134            state: Claimed {
135                daemon_id,
136                claimed_at: chrono::Utc::now(),
137                retry_attempt: self.state.retry_attempt, // Carry over retry attempt
138                batch_expires_at: self.state.batch_expires_at, // Carry over batch deadline
139                // This single-row claim path does not run the leaky-bucket gate.
140                leak: None,
141            },
142        };
143        storage.persist(&request).await?;
144        Ok(request)
145    }
146
147    pub async fn cancel<S: Storage + ?Sized>(self, storage: &S) -> Result<Request<Canceled>> {
148        let request = Request {
149            data: self.data,
150            state: Canceled {
151                canceled_at: chrono::Utc::now(),
152            },
153        };
154        storage.persist(&request).await?;
155        Ok(request)
156    }
157}
158
159impl Request<Claimed> {
160    pub async fn unclaim<S: Storage + ?Sized>(self, storage: &S) -> Result<Request<Pending>> {
161        let request = Request {
162            data: self.data,
163            state: Pending {
164                retry_attempt: self.state.retry_attempt, // Preserve retry attempt
165                not_before: None,                        // Can be claimed immediately
166                batch_expires_at: self.state.batch_expires_at, // Carry over batch deadline
167            },
168        };
169        storage.persist(&request).await?;
170        Ok(request)
171    }
172
173    pub async fn cancel<S: Storage + ?Sized>(self, storage: &S) -> Result<Request<Canceled>> {
174        let request = Request {
175            data: self.data,
176            state: Canceled {
177                canceled_at: chrono::Utc::now(),
178            },
179        };
180        storage.persist(&request).await?;
181        Ok(request)
182    }
183
184    pub async fn process<S, Fut>(
185        self,
186        storage: &S,
187        response_fut: Fut,
188    ) -> Result<Request<Processing>>
189    where
190        S: Storage,
191        Fut: std::future::Future<Output = Result<HttpResponse>> + Send + 'static,
192    {
193        // Create a channel for the HTTP result
194        let (tx, rx) = tokio::sync::mpsc::channel(1);
195
196        // Spawn the HTTP request as an async task, propagating the current
197        // span so that the execute span becomes a child of process_request.
198        let current_span = tracing::Span::current();
199        let task_handle = tokio::spawn(
200            async move {
201                let result = response_fut.await;
202                let _ = tx.send(result).await; // Ignore send errors (receiver dropped)
203            }
204            .instrument(current_span),
205        );
206
207        let processing_state = Processing {
208            daemon_id: self.state.daemon_id,
209            claimed_at: self.state.claimed_at,
210            started_at: chrono::Utc::now(),
211            retry_attempt: self.state.retry_attempt,
212            batch_expires_at: self.state.batch_expires_at,
213            result_rx: Arc::new(Mutex::new(rx)),
214            abort_handle: task_handle.abort_handle(),
215        };
216
217        let request = Request {
218            data: self.data,
219            state: processing_state,
220        };
221
222        // Persist the Processing state so we can cancel it if needed
223        // If persist fails, abort the spawned HTTP task
224        if let Err(e) = storage.persist(&request).await {
225            request.state.abort_handle.abort();
226            return Err(e);
227        }
228
229        Ok(request)
230    }
231}
232
233/// Configuration for retry behavior.
234#[derive(Debug, Clone)]
235pub struct RetryConfig {
236    pub max_retries: Option<u32>,
237    pub stop_before_deadline_ms: Option<i64>,
238    pub backoff_ms: u64,
239    pub backoff_factor: u64,
240    pub max_backoff_ms: u64,
241}
242
243impl Request<Failed> {
244    /// Attempt to retry this failed request.
245    ///
246    /// If retries are available, transitions the request back to Pending with:
247    /// - Incremented retry_attempt
248    /// - Calculated not_before timestamp for exponential backoff
249    ///
250    /// If no retries remain, returns None and the request stays Failed.
251    ///
252    /// The retry logic considers:
253    /// - max_retries: Hard cap on total retry attempts
254    /// - stop_before_deadline_ms: Deadline-aware retry (stops before batch expiration)
255    pub fn can_retry(
256        self,
257        retry_attempt: u32,
258        config: RetryConfig,
259    ) -> std::result::Result<Request<Pending>, Box<Self>> {
260        // Calculate exponential backoff: backoff_ms * (backoff_factor ^ retry_attempt)
261        let backoff_duration = {
262            let exponential = config
263                .backoff_ms
264                .saturating_mul(config.backoff_factor.saturating_pow(retry_attempt));
265            exponential.min(config.max_backoff_ms)
266        };
267
268        let now = chrono::Utc::now();
269        let not_before = now + chrono::Duration::milliseconds(backoff_duration as i64);
270
271        if let Some(max_retries) = config.max_retries
272            && retry_attempt >= max_retries
273        {
274            return Err(Box::new(self));
275        }
276
277        // Determine the effective deadline (with or without buffer)
278        let effective_deadline = if let Some(stop_before_deadline_ms) =
279            config.stop_before_deadline_ms
280        {
281            self.state.batch_expires_at - chrono::Duration::milliseconds(stop_before_deadline_ms)
282        } else {
283            // No buffer configured - use the actual deadline
284            self.state.batch_expires_at
285        };
286
287        // Check if the next retry would start before the effective deadline
288        if not_before >= effective_deadline {
289            return Err(Box::new(self));
290        }
291
292        // state_transition span emitted by caller after persist
293
294        let request = Request {
295            data: self.data,
296            state: Pending {
297                retry_attempt: retry_attempt + 1,
298                not_before: Some(not_before),
299                batch_expires_at: self.state.batch_expires_at,
300            },
301        };
302
303        Ok(request)
304    }
305}
306
307impl Request<Processing> {
308    /// Wait for the HTTP request to complete.
309    ///
310    /// This method awaits the result from the spawned HTTP task and transitions
311    /// the request to one of three terminal states: `Completed`, `Failed`, or `Canceled`.
312    ///
313    /// The `should_retry` predicate determines whether a response should be considered
314    /// a failure (and thus eligible for retry) or a success.
315    ///
316    /// The `cancellation` future allows external cancellation of the request. It should
317    /// resolve to a `CancellationReason`:
318    /// - `CancellationReason::User`: User-initiated cancellation (persists Canceled state)
319    /// - `CancellationReason::Shutdown`: Daemon shutdown (aborts HTTP but doesn't persist)
320    ///
321    /// Returns:
322    /// - `RequestCompletionResult::Completed` if the HTTP request succeeded
323    /// - `RequestCompletionResult::Failed` if the HTTP request failed or should be retried
324    /// - `RequestCompletionResult::Canceled` if the request was canceled by user
325    /// - `Err(FusilladeError::Shutdown)` if the daemon is shutting down
326    pub async fn complete<S, F, Fut>(
327        self,
328        storage: &S,
329        should_retry: F,
330        cancellation: Fut,
331    ) -> Result<RequestCompletionResult>
332    where
333        S: Storage + ?Sized,
334        F: Fn(&HttpResponse) -> bool,
335        Fut: std::future::Future<Output = CancellationReason>,
336    {
337        // Await the result from the channel (lock the mutex to access the receiver)
338        // We use an enum to track whether we got a result or cancellation so we can
339        // drop the mutex guard before calling self.cancel()
340        enum Outcome {
341            Result(Option<std::result::Result<HttpResponse, FusilladeError>>),
342            Canceled(CancellationReason),
343        }
344
345        let outcome = {
346            let mut rx = self.state.result_rx.lock().await;
347
348            tokio::select! {
349                // Wait for the HTTP request to finish processing
350                result = rx.recv() => Outcome::Result(result),
351                // Handle cancellation
352                reason = cancellation => Outcome::Canceled(reason),
353            }
354        };
355
356        // Handle cancellation outside the mutex guard
357        let result = match outcome {
358            Outcome::Canceled(CancellationReason::User) => {
359                // User cancellation: abort HTTP task but don't persist state change.
360                // The batch's cancelling_at flag causes these requests to be counted
361                // as canceled in queries, so no individual UPDATE is needed.
362                self.state.abort_handle.abort();
363                let canceled = Request {
364                    data: self.data,
365                    state: Canceled {
366                        canceled_at: chrono::Utc::now(),
367                    },
368                };
369                return Ok(RequestCompletionResult::Canceled(canceled));
370            }
371            Outcome::Canceled(CancellationReason::Shutdown) => {
372                // Shutdown: abort HTTP task but don't persist state change
373                // Request stays in Processing state and will be reclaimed later
374                self.state.abort_handle.abort();
375                return Err(FusilladeError::Shutdown);
376            }
377            Outcome::Result(result) => result,
378        };
379
380        match result {
381            Some(Ok(http_response)) => {
382                // Check if this is an error response (4xx or 5xx)
383                let is_error = http_response.status >= 400;
384
385                // Check if this response should be retried
386                if should_retry(&http_response) {
387                    // Treat as failure for retry purposes
388                    let failed_state = Failed {
389                        reason: FailureReason::RetriableHttpStatus {
390                            status: http_response.status,
391                            body: http_response.body.clone(),
392                        },
393                        failed_at: chrono::Utc::now(),
394                        retry_attempt: self.state.retry_attempt,
395                        batch_expires_at: self.state.batch_expires_at,
396                        routed_model: self.data.model.clone(),
397                    };
398                    let request = Request {
399                        data: self.data,
400                        state: failed_state,
401                    };
402                    Ok(RequestCompletionResult::Failed(request))
403                } else if is_error {
404                    // Non-retriable error (e.g., 4xx client errors)
405                    // Mark as failed but don't retry
406                    let failed_state = Failed {
407                        reason: FailureReason::NonRetriableHttpStatus {
408                            status: http_response.status,
409                            body: http_response.body.clone(),
410                        },
411                        failed_at: chrono::Utc::now(),
412                        retry_attempt: self.state.retry_attempt,
413                        batch_expires_at: self.state.batch_expires_at,
414                        routed_model: self.data.model.clone(),
415                    };
416                    let request = Request {
417                        data: self.data,
418                        state: failed_state,
419                    };
420                    storage.persist(&request).await?;
421                    Ok(RequestCompletionResult::Failed(request))
422                } else {
423                    // HTTP request completed successfully
424                    let completed_state = Completed {
425                        response_status: http_response.status,
426                        response_body: http_response.body,
427                        claimed_at: self.state.claimed_at,
428                        started_at: self.state.started_at,
429                        completed_at: chrono::Utc::now(),
430                        routed_model: self.data.model.clone(),
431                    };
432                    let request = Request {
433                        data: self.data,
434                        state: completed_state,
435                    };
436                    storage.persist(&request).await?;
437                    Ok(RequestCompletionResult::Completed(request))
438                }
439            }
440            Some(Err(e)) => {
441                let reason = match &e {
442                    FusilladeError::HttpRequestBuilder(error) => {
443                        FailureReason::RequestBuilderError {
444                            error: error.clone(),
445                        }
446                    }
447                    FusilladeError::HttpClientTimeout(error) => FailureReason::Timeout {
448                        error: error.clone(),
449                    },
450                    FusilladeError::FirstChunkTimeout(msg) => {
451                        FailureReason::Timeout { error: msg.clone() }
452                    }
453                    FusilladeError::TokensTimeout(msg) => {
454                        FailureReason::Timeout { error: msg.clone() }
455                    }
456                    FusilladeError::BodyTimeout(msg) => {
457                        FailureReason::Timeout { error: msg.clone() }
458                    }
459                    _ => FailureReason::NetworkError {
460                        error: crate::error::error_serialization::serialize_error(&e.into()),
461                    },
462                };
463
464                let failed_state = Failed {
465                    reason,
466                    failed_at: chrono::Utc::now(),
467                    retry_attempt: self.state.retry_attempt,
468                    batch_expires_at: self.state.batch_expires_at,
469                    routed_model: self.data.model.clone(),
470                };
471                let request = Request {
472                    data: self.data,
473                    state: failed_state,
474                };
475                Ok(RequestCompletionResult::Failed(request))
476            }
477            None => {
478                // Channel closed - task died without sending a result
479                let failed_state = Failed {
480                    reason: FailureReason::TaskTerminated,
481                    failed_at: chrono::Utc::now(),
482                    retry_attempt: self.state.retry_attempt,
483                    batch_expires_at: self.state.batch_expires_at,
484                    routed_model: self.data.model.clone(),
485                };
486                let request = Request {
487                    data: self.data,
488                    state: failed_state,
489                };
490                storage.persist(&request).await?;
491                Ok(RequestCompletionResult::Failed(request))
492            }
493        }
494    }
495
496    pub async fn cancel<S: Storage + ?Sized>(self, storage: &S) -> Result<Request<Canceled>> {
497        // Abort the in-flight HTTP request
498        self.state.abort_handle.abort();
499
500        let request = Request {
501            data: self.data,
502            state: Canceled {
503                canceled_at: chrono::Utc::now(),
504            },
505        };
506        storage.persist(&request).await?;
507        Ok(request)
508    }
509}