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, oneshot};
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 channels for dispatching and receiving the HTTP result. The
194        // dispatch gate keeps the response future completely unpolled until
195        // the Processing transition is durable.
196        let (dispatch_tx, dispatch_rx) = oneshot::channel();
197        let (tx, rx) = tokio::sync::mpsc::channel(1);
198
199        // Spawn the HTTP request as an async task, propagating the current
200        // span so that the execute span becomes a child of process_request.
201        let current_span = tracing::Span::current();
202        let task_handle = tokio::spawn(
203            async move {
204                if dispatch_rx.await.is_err() {
205                    return;
206                }
207                let result = response_fut.await;
208                let _ = tx.send(result).await; // Ignore send errors (receiver dropped)
209            }
210            .instrument(current_span),
211        );
212
213        let processing_state = Processing {
214            daemon_id: self.state.daemon_id,
215            claimed_at: self.state.claimed_at,
216            started_at: chrono::Utc::now(),
217            retry_attempt: self.state.retry_attempt,
218            batch_expires_at: self.state.batch_expires_at,
219            result_rx: Arc::new(Mutex::new(rx)),
220            abort_handle: task_handle.abort_handle(),
221        };
222
223        let mut request = Request {
224            data: self.data,
225            state: processing_state,
226        };
227
228        // Persist the Processing state so we can cancel it if needed
229        // If persist fails, abort the spawned HTTP task
230        if let Err(e) = storage.persist(&request).await {
231            request.state.abort_handle.abort();
232            return Err(e);
233        }
234
235        // Storage admission may have waited behind the state-write limiter.
236        // Keep in-memory timeout accounting aligned with actual dispatch;
237        // Postgres storage stamps its durable value after acquiring the permit.
238        request.state.started_at = chrono::Utc::now();
239
240        if dispatch_tx.send(()).is_err() {
241            request.state.abort_handle.abort();
242            return Err(FusilladeError::Other(anyhow::anyhow!(
243                "HTTP dispatch task terminated before request processing began"
244            )));
245        }
246
247        Ok(request)
248    }
249}
250
251/// Configuration for retry behavior.
252#[derive(Debug, Clone)]
253pub struct RetryConfig {
254    pub max_retries: Option<u32>,
255    pub stop_before_deadline_ms: Option<i64>,
256    pub backoff_ms: u64,
257    pub backoff_factor: u64,
258    pub max_backoff_ms: u64,
259}
260
261impl Request<Failed> {
262    /// Attempt to retry this failed request.
263    ///
264    /// If retries are available, transitions the request back to Pending with:
265    /// - Incremented retry_attempt
266    /// - Calculated not_before timestamp for exponential backoff
267    ///
268    /// If no retries remain, returns None and the request stays Failed.
269    ///
270    /// The retry logic considers:
271    /// - max_retries: Hard cap on total retry attempts
272    /// - stop_before_deadline_ms: Deadline-aware retry (stops before batch expiration)
273    pub fn can_retry(
274        self,
275        retry_attempt: u32,
276        config: RetryConfig,
277    ) -> std::result::Result<Request<Pending>, Box<Self>> {
278        // Calculate exponential backoff: backoff_ms * (backoff_factor ^ retry_attempt)
279        let backoff_duration = {
280            let exponential = config
281                .backoff_ms
282                .saturating_mul(config.backoff_factor.saturating_pow(retry_attempt));
283            exponential.min(config.max_backoff_ms)
284        };
285
286        let now = chrono::Utc::now();
287        let not_before = now + chrono::Duration::milliseconds(backoff_duration as i64);
288
289        if let Some(max_retries) = config.max_retries
290            && retry_attempt >= max_retries
291        {
292            return Err(Box::new(self));
293        }
294
295        // Determine the effective deadline (with or without buffer)
296        let effective_deadline = if let Some(stop_before_deadline_ms) =
297            config.stop_before_deadline_ms
298        {
299            self.state.batch_expires_at - chrono::Duration::milliseconds(stop_before_deadline_ms)
300        } else {
301            // No buffer configured - use the actual deadline
302            self.state.batch_expires_at
303        };
304
305        // Check if the next retry would start before the effective deadline
306        if not_before >= effective_deadline {
307            return Err(Box::new(self));
308        }
309
310        // state_transition span emitted by caller after persist
311
312        let request = Request {
313            data: self.data,
314            state: Pending {
315                retry_attempt: retry_attempt + 1,
316                not_before: Some(not_before),
317                batch_expires_at: self.state.batch_expires_at,
318            },
319        };
320
321        Ok(request)
322    }
323}
324
325impl Request<Processing> {
326    /// Wait for the HTTP request to complete.
327    ///
328    /// This method awaits the result from the spawned HTTP task and transitions
329    /// the request to one of three terminal states: `Completed`, `Failed`, or `Canceled`.
330    ///
331    /// The `should_retry` predicate determines whether a response should be considered
332    /// a failure (and thus eligible for retry) or a success.
333    ///
334    /// The `cancellation` future allows external cancellation of the request. It should
335    /// resolve to a `CancellationReason`:
336    /// - `CancellationReason::User`: User-initiated cancellation (persists Canceled state)
337    /// - `CancellationReason::Shutdown`: Daemon shutdown (aborts HTTP but doesn't persist)
338    ///
339    /// Returns:
340    /// - `RequestCompletionResult::Completed` if the HTTP request succeeded
341    /// - `RequestCompletionResult::Failed` if the HTTP request failed or should be retried
342    /// - `RequestCompletionResult::Canceled` if the request was canceled by user
343    /// - `Err(FusilladeError::Shutdown)` if the daemon is shutting down
344    pub async fn complete<S, F, Fut>(
345        self,
346        storage: &S,
347        should_retry: F,
348        cancellation: Fut,
349    ) -> Result<RequestCompletionResult>
350    where
351        S: Storage + ?Sized,
352        F: Fn(&HttpResponse) -> bool,
353        Fut: std::future::Future<Output = CancellationReason>,
354    {
355        // Await the result from the channel (lock the mutex to access the receiver)
356        // We use an enum to track whether we got a result or cancellation so we can
357        // drop the mutex guard before calling self.cancel()
358        enum Outcome {
359            Result(Option<std::result::Result<HttpResponse, FusilladeError>>),
360            Canceled(CancellationReason),
361        }
362
363        let outcome = {
364            let mut rx = self.state.result_rx.lock().await;
365
366            tokio::select! {
367                // Wait for the HTTP request to finish processing
368                result = rx.recv() => Outcome::Result(result),
369                // Handle cancellation
370                reason = cancellation => Outcome::Canceled(reason),
371            }
372        };
373
374        // Handle cancellation outside the mutex guard
375        let result = match outcome {
376            Outcome::Canceled(CancellationReason::User) => {
377                // User cancellation: abort HTTP task but don't persist state change.
378                // The batch's cancelling_at flag causes these requests to be counted
379                // as canceled in queries, so no individual UPDATE is needed.
380                self.state.abort_handle.abort();
381                let canceled = Request {
382                    data: self.data,
383                    state: Canceled {
384                        canceled_at: chrono::Utc::now(),
385                    },
386                };
387                return Ok(RequestCompletionResult::Canceled(canceled));
388            }
389            Outcome::Canceled(CancellationReason::Shutdown) => {
390                // Shutdown: abort HTTP task but don't persist state change
391                // Request stays in Processing state and will be reclaimed later
392                self.state.abort_handle.abort();
393                return Err(FusilladeError::Shutdown);
394            }
395            Outcome::Result(result) => result,
396        };
397
398        match result {
399            Some(Ok(http_response)) => {
400                // Check if this is an error response (4xx or 5xx)
401                let is_error = http_response.status >= 400;
402
403                // Check if this response should be retried
404                if should_retry(&http_response) {
405                    // Treat as failure for retry purposes
406                    let failed_state = Failed {
407                        reason: FailureReason::RetriableHttpStatus {
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                    Ok(RequestCompletionResult::Failed(request))
421                } else if is_error {
422                    // Non-retriable error (e.g., 4xx client errors)
423                    // Mark as failed but don't retry
424                    let failed_state = Failed {
425                        reason: FailureReason::NonRetriableHttpStatus {
426                            status: http_response.status,
427                            body: http_response.body.clone(),
428                        },
429                        failed_at: chrono::Utc::now(),
430                        retry_attempt: self.state.retry_attempt,
431                        batch_expires_at: self.state.batch_expires_at,
432                        routed_model: self.data.model.clone(),
433                    };
434                    let request = Request {
435                        data: self.data,
436                        state: failed_state,
437                    };
438                    storage.persist(&request).await?;
439                    Ok(RequestCompletionResult::Failed(request))
440                } else {
441                    // HTTP request completed successfully
442                    let completed_state = Completed {
443                        response_status: http_response.status,
444                        response_body: http_response.body,
445                        claimed_at: self.state.claimed_at,
446                        started_at: self.state.started_at,
447                        completed_at: chrono::Utc::now(),
448                        routed_model: self.data.model.clone(),
449                    };
450                    let request = Request {
451                        data: self.data,
452                        state: completed_state,
453                    };
454                    storage.persist(&request).await?;
455                    Ok(RequestCompletionResult::Completed(request))
456                }
457            }
458            Some(Err(e)) => {
459                let reason = match &e {
460                    FusilladeError::HttpRequestBuilder(error) => {
461                        FailureReason::RequestBuilderError {
462                            error: error.clone(),
463                        }
464                    }
465                    FusilladeError::HttpClientTimeout(error) => FailureReason::Timeout {
466                        error: error.clone(),
467                    },
468                    FusilladeError::FirstChunkTimeout(msg) => {
469                        FailureReason::Timeout { error: msg.clone() }
470                    }
471                    FusilladeError::UploadStallTimeout(msg) => {
472                        FailureReason::Timeout { error: msg.clone() }
473                    }
474                    FusilladeError::TokensTimeout(msg) => {
475                        FailureReason::Timeout { error: msg.clone() }
476                    }
477                    FusilladeError::BodyTimeout(msg) => {
478                        FailureReason::Timeout { error: msg.clone() }
479                    }
480                    _ => FailureReason::NetworkError {
481                        error: crate::error::error_serialization::serialize_error(&e.into()),
482                    },
483                };
484
485                let failed_state = Failed {
486                    reason,
487                    failed_at: chrono::Utc::now(),
488                    retry_attempt: self.state.retry_attempt,
489                    batch_expires_at: self.state.batch_expires_at,
490                    routed_model: self.data.model.clone(),
491                };
492                let request = Request {
493                    data: self.data,
494                    state: failed_state,
495                };
496                Ok(RequestCompletionResult::Failed(request))
497            }
498            None => {
499                // Channel closed - task died without sending a result
500                let failed_state = Failed {
501                    reason: FailureReason::TaskTerminated,
502                    failed_at: chrono::Utc::now(),
503                    retry_attempt: self.state.retry_attempt,
504                    batch_expires_at: self.state.batch_expires_at,
505                    routed_model: self.data.model.clone(),
506                };
507                let request = Request {
508                    data: self.data,
509                    state: failed_state,
510                };
511                storage.persist(&request).await?;
512                Ok(RequestCompletionResult::Failed(request))
513            }
514        }
515    }
516
517    pub async fn cancel<S: Storage + ?Sized>(self, storage: &S) -> Result<Request<Canceled>> {
518        // Abort the in-flight HTTP request
519        self.state.abort_handle.abort();
520
521        let request = Request {
522            data: self.data,
523            state: Canceled {
524                canceled_at: chrono::Utc::now(),
525            },
526        };
527        storage.persist(&request).await?;
528        Ok(request)
529    }
530}