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}