microsoft/hve-core
Publicmirrored from https://github.com/microsoft/hve-coreAvailable
.github/instructions/coding-standards/rust/rust.instructions.md
675lines · modecode
| 1 | --- |
| 2 | applyTo: '**/*.rs' |
| 3 | description: 'Required instructions for Rust research, planning, implementation, editing, or creating - Brought to you by microsoft/hve-core' |
| 4 | --- |
| 5 | |
| 6 | # Rust Instructions |
| 7 | |
| 8 | Conventions for Rust development targeting the 2021 edition. |
| 9 | |
| 10 | ## Project Structure |
| 11 | |
| 12 | Crates follow a standard layout: |
| 13 | |
| 14 | ```text |
| 15 | Cargo.toml |
| 16 | Cargo.lock |
| 17 | src/ |
| 18 | main.rs # Binary crate entry point |
| 19 | lib.rs # Library crate root |
| 20 | module_name.rs # Top-level module |
| 21 | module_name/ |
| 22 | mod.rs # Module with submodules |
| 23 | submodule.rs |
| 24 | tests/ |
| 25 | integration_test.rs |
| 26 | ``` |
| 27 | |
| 28 | * `Cargo.toml` and `Cargo.lock` at crate root. |
| 29 | * Commit `Cargo.lock` for binary crates to ensure reproducible builds. Exclude it from version control for library crates. |
| 30 | * `src/` contains all source files. |
| 31 | * Binary crates use `main.rs`; library crates use `lib.rs`. |
| 32 | * Test projects use a sibling `tests/` directory for integration tests. |
| 33 | * Keep crate roots thin with module declarations and re-exports. |
| 34 | |
| 35 | Project folder organization scales with complexity. Keep all files at root-level modules when fewer than 10 source files exist. When folders become necessary, organize by domain responsibility: `config`, `error`, `handlers`, `models`, `services`. |
| 36 | |
| 37 | ## Cargo.toml Conventions |
| 38 | |
| 39 | ### Standard Fields |
| 40 | |
| 41 | <!-- <example-cargo-toml> --> |
| 42 | ```toml |
| 43 | [package] |
| 44 | name = "service-name" |
| 45 | version = "0.1.0" |
| 46 | edition = "2021" |
| 47 | license = "MIT" |
| 48 | |
| 49 | [dependencies] |
| 50 | tokio = { version = "1", features = ["full"] } |
| 51 | serde = { version = "1", features = ["derive"] } |
| 52 | serde_json = "1" |
| 53 | thiserror = "2" |
| 54 | tracing = "0.1" |
| 55 | tracing-subscriber = { version = "0.3", features = ["env-filter"] } |
| 56 | ``` |
| 57 | <!-- </example-cargo-toml> --> |
| 58 | |
| 59 | Sections below reference additional crates (`reqwest`, `tokio-retry`, `async-trait`). Add them to `[dependencies]` when using those patterns. |
| 60 | |
| 61 | ### Dependency Management |
| 62 | |
| 63 | * Use caret ranges for stable public crates: `version = "1"`. |
| 64 | * Pin exact versions for private or unstable SDKs: `version = "=1.1.3"`. |
| 65 | * Disable default features when targeting WASM or minimal builds: `default-features = false`. |
| 66 | * Specify only needed feature flags to reduce compile time and binary size. |
| 67 | |
| 68 | ### Release Profile |
| 69 | |
| 70 | <!-- <example-release-profile> --> |
| 71 | ```toml |
| 72 | [profile.release] |
| 73 | strip = true |
| 74 | lto = true |
| 75 | codegen-units = 1 |
| 76 | panic = "abort" |
| 77 | ``` |
| 78 | <!-- </example-release-profile> --> |
| 79 | |
| 80 | Use `strip = true` to remove debug symbols. Enable `lto` and `codegen-units = 1` for optimized builds. Set `panic = "abort"` for smaller binaries when stack unwinding is unnecessary. |
| 81 | |
| 82 | ## Coding Conventions |
| 83 | |
| 84 | ### Naming |
| 85 | |
| 86 | | Element | Convention | Example | |
| 87 | |-----------------|----------------------|-------------------------------| |
| 88 | | Types & Structs | PascalCase | `UserService`, `DeviceConfig` | |
| 89 | | Traits | PascalCase | `Repository`, `Serializer` | |
| 90 | | Enum Variants | PascalCase | `Status::Active` | |
| 91 | | Functions | snake_case | `process_request` | |
| 92 | | Variables | snake_case | `device_url`, `retry_count` | |
| 93 | | Constants | SCREAMING_SNAKE_CASE | `DEFAULT_TIMEOUT` | |
| 94 | | Modules | snake_case | `error_handler` | |
| 95 | | Crate Names | kebab-case | `my-service` | |
| 96 | | Feature Flags | kebab-case | `onnx-runtime` | |
| 97 | |
| 98 | ### Type Naming Suffixes |
| 99 | |
| 100 | Apply these suffixes consistently for domain types: |
| 101 | |
| 102 | * Error types: `*Error` suffix (`ServiceError`, `ParseError`) |
| 103 | * Config types: `*Config` suffix (`AppConfig`, `DatabaseConfig`) |
| 104 | * Builder types: `*Builder` suffix (`RequestBuilder`, `SessionBuilder`) |
| 105 | * Result type aliases: `pub type Result<T> = std::result::Result<T, ServiceError>;` |
| 106 | |
| 107 | ### Module Structure |
| 108 | |
| 109 | Member ordering within a module: |
| 110 | |
| 111 | 1. `use` declarations (standard library, external crates, internal modules) |
| 112 | 2. Constants and statics |
| 113 | 3. Type definitions (structs, enums, type aliases) |
| 114 | 4. Trait definitions |
| 115 | 5. Trait implementations |
| 116 | 6. Inherent implementations |
| 117 | 7. Free functions |
| 118 | 8. Test module |
| 119 | |
| 120 | Within categories, order: `pub` items before `pub(crate)` before private. |
| 121 | |
| 122 | ### Variable Declarations |
| 123 | |
| 124 | Prefer type inference with `let` when the type is obvious from the right side. Use explicit type annotations when inference is ambiguous or the type aids readability: |
| 125 | |
| 126 | ```rust |
| 127 | let service = UserService::new(repo, logger); |
| 128 | let lookup: HashMap<String, Vec<Item>> = HashMap::new(); |
| 129 | ``` |
| 130 | |
| 131 | Prefer early returns over deep nesting. Use `if let` and `let ... else` for option/result unwrapping at control flow boundaries: |
| 132 | |
| 133 | ```rust |
| 134 | let Some(user) = repository.find(id).await? else { |
| 135 | return Err(ServiceError::not_found("User not found")); |
| 136 | }; |
| 137 | ``` |
| 138 | |
| 139 | ## Error Handling |
| 140 | |
| 141 | ### Custom Error Types |
| 142 | |
| 143 | Use `thiserror` for library-style error enums. Define a module-scoped `Result` type alias to reduce boilerplate. |
| 144 | |
| 145 | <!-- <example-error-handling> --> |
| 146 | ```rust |
| 147 | use thiserror::Error; |
| 148 | |
| 149 | pub type Result<T> = std::result::Result<T, ServiceError>; |
| 150 | |
| 151 | #[derive(Error, Debug)] |
| 152 | pub enum ServiceError { |
| 153 | #[error("Not found: {message}")] |
| 154 | NotFound { message: String }, |
| 155 | |
| 156 | #[error("Invalid input: {message}")] |
| 157 | InvalidInput { message: String }, |
| 158 | |
| 159 | #[error("IO error: {0}")] |
| 160 | Io(#[from] std::io::Error), |
| 161 | |
| 162 | #[error("Serialization error: {0}")] |
| 163 | Serialization(#[from] serde_json::Error), |
| 164 | } |
| 165 | |
| 166 | impl ServiceError { |
| 167 | pub fn not_found<S: Into<String>>(message: S) -> Self { |
| 168 | Self::NotFound { message: message.into() } |
| 169 | } |
| 170 | |
| 171 | pub fn invalid_input<S: Into<String>>(message: S) -> Self { |
| 172 | Self::InvalidInput { message: message.into() } |
| 173 | } |
| 174 | } |
| 175 | ``` |
| 176 | <!-- </example-error-handling> --> |
| 177 | |
| 178 | ### Error Handling Rules |
| 179 | |
| 180 | * Use `thiserror` for error types in libraries and modules with structured error variants. |
| 181 | * Use `anyhow` only in application-level `main` functions or CLI tools where error granularity is unnecessary. |
| 182 | * Prefer `?` operator for error propagation over explicit `match` or `unwrap`. |
| 183 | * Never use `unwrap()` or `expect()` in production paths. Reserve them for cases with compile-time or initialization guarantees. |
| 184 | * Initialization paths include startup config loading in `main`, `OnceLock`/`LazyLock` initializers, and one-time setup that runs before the service accepts work. Use `expect()` with a descriptive message in these contexts. |
| 185 | * Provide context-aware error messages that include relevant state. |
| 186 | * Implement `#[from]` for delegating errors from external crates. |
| 187 | * Add helper constructors on error types for ergonomic creation. |
| 188 | |
| 189 | ## Async Patterns |
| 190 | |
| 191 | ### Tokio Runtime |
| 192 | |
| 193 | Use Tokio as the async runtime. Select the flavor based on workload characteristics: |
| 194 | |
| 195 | <!-- <example-tokio-runtime> --> |
| 196 | ```rust |
| 197 | // Multi-threaded for high-concurrency services |
| 198 | #[tokio::main] |
| 199 | async fn main() -> Result<()> { |
| 200 | // ... |
| 201 | } |
| 202 | |
| 203 | // Single-threaded for lightweight or resource-constrained services |
| 204 | #[tokio::main(flavor = "current_thread")] |
| 205 | async fn main() -> Result<()> { |
| 206 | // ... |
| 207 | } |
| 208 | ``` |
| 209 | <!-- </example-tokio-runtime> --> |
| 210 | |
| 211 | ### Concurrent Task Management |
| 212 | |
| 213 | * Use `tokio::select!` for racing independent tasks that should run concurrently until one completes. |
| 214 | * Use `tokio::try_join!` for collecting results from concurrent tasks that must all succeed. |
| 215 | * Use `tokio::spawn` for background tasks that run independently. |
| 216 | * Handle task cancellation and shutdown gracefully via `CancellationToken` or `tokio::select!`. |
| 217 | * Never block the async runtime with synchronous operations; use `tokio::task::spawn_blocking` instead. |
| 218 | |
| 219 | <!-- <example-concurrency> --> |
| 220 | ```rust |
| 221 | tokio::select! { |
| 222 | result = background_task() => result?, |
| 223 | result = server.run() => result?, |
| 224 | } |
| 225 | ``` |
| 226 | <!-- </example-concurrency> --> |
| 227 | |
| 228 | ### Async Trait Implementations |
| 229 | |
| 230 | Use `async-trait` for trait definitions requiring async methods. The `async-trait` crate is required for the 2021 edition. Native async trait support is available in the 2024 edition and later. |
| 231 | |
| 232 | ```rust |
| 233 | use async_trait::async_trait; |
| 234 | |
| 235 | #[async_trait] |
| 236 | pub trait Repository: Send + Sync { |
| 237 | async fn find_by_id(&self, id: &str) -> Result<Option<Item>>; |
| 238 | async fn save(&self, item: &Item) -> Result<()>; |
| 239 | } |
| 240 | ``` |
| 241 | |
| 242 | ## Observability |
| 243 | |
| 244 | ### Structured Logging |
| 245 | |
| 246 | Use the `tracing` crate for all logging. Never use `println!` or `eprintln!` in production code. |
| 247 | |
| 248 | <!-- <example-tracing> --> |
| 249 | ```rust |
| 250 | use tracing::{info, warn, error, debug}; |
| 251 | use tracing_subscriber::filter::EnvFilter; |
| 252 | |
| 253 | tracing_subscriber::fmt() |
| 254 | .with_env_filter(EnvFilter::from_default_env()) |
| 255 | .init(); |
| 256 | |
| 257 | info!( |
| 258 | endpoint = %endpoint, |
| 259 | interval_secs = %interval, |
| 260 | "Starting service with configuration" |
| 261 | ); |
| 262 | |
| 263 | error!( |
| 264 | error_code = "PUBLISH_FAILED", |
| 265 | topic = %topic, |
| 266 | "Failed to publish message: {:?}", err |
| 267 | ); |
| 268 | ``` |
| 269 | <!-- </example-tracing> --> |
| 270 | |
| 271 | ### OpenTelemetry Integration |
| 272 | |
| 273 | When distributed tracing is required, integrate OpenTelemetry via `tracing-opentelemetry`. Add `tracing-opentelemetry`, `opentelemetry`, `opentelemetry-sdk` (with the `rt-tokio` feature), and `opentelemetry-otlp` to `[dependencies]`. |
| 274 | |
| 275 | * Check for `OTEL_EXPORTER_OTLP_ENDPOINT` environment variable before enabling the exporter. |
| 276 | * Fall back to console-only logging when the variable is absent. |
| 277 | * Use `TraceContextPropagator` for W3C trace context propagation. |
| 278 | |
| 279 | ```rust |
| 280 | use std::env; |
| 281 | |
| 282 | use tracing_subscriber::layer::SubscriberExt; |
| 283 | |
| 284 | fn init_tracing() { |
| 285 | let subscriber = tracing_subscriber::fmt() |
| 286 | .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()); |
| 287 | |
| 288 | if env::var("OTEL_EXPORTER_OTLP_ENDPOINT").is_ok() { |
| 289 | let tracer = opentelemetry_otlp::new_pipeline() |
| 290 | .tracing() |
| 291 | .install_batch(opentelemetry_sdk::runtime::Tokio) |
| 292 | .expect("OpenTelemetry pipeline must initialize at startup"); |
| 293 | |
| 294 | let telemetry = tracing_opentelemetry::layer().with_tracer(tracer); |
| 295 | tracing::subscriber::set_global_default( |
| 296 | subscriber.finish().with(telemetry), |
| 297 | ).expect("Global subscriber must be set once at startup"); |
| 298 | } else { |
| 299 | subscriber.init(); |
| 300 | } |
| 301 | } |
| 302 | ``` |
| 303 | |
| 304 | ## Serialization |
| 305 | |
| 306 | ### Serde Patterns |
| 307 | |
| 308 | * Derive `Serialize` and `Deserialize` using `serde` for all data transfer types. |
| 309 | * Use `#[serde(rename_all = "camelCase")]` when interfacing with JSON APIs that use camelCase. |
| 310 | * Implement `Default` for configuration types to provide sensible fallback values. |
| 311 | * Use `#[serde(default = "...")]` for fields with non-trivial defaults. |
| 312 | |
| 313 | <!-- <example-serde> --> |
| 314 | ```rust |
| 315 | #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] |
| 316 | pub struct AppConfig { |
| 317 | pub endpoint: String, |
| 318 | pub polling_interval_secs: u64, |
| 319 | #[serde(default = "default_timeout")] |
| 320 | pub timeout_ms: u64, |
| 321 | } |
| 322 | |
| 323 | fn default_timeout() -> u64 { 5000 } |
| 324 | |
| 325 | impl Default for AppConfig { |
| 326 | fn default() -> Self { |
| 327 | Self { |
| 328 | endpoint: String::new(), |
| 329 | polling_interval_secs: 10, |
| 330 | timeout_ms: default_timeout(), |
| 331 | } |
| 332 | } |
| 333 | } |
| 334 | ``` |
| 335 | <!-- </example-serde> --> |
| 336 | |
| 337 | ## Configuration Management |
| 338 | |
| 339 | ### Environment Variables |
| 340 | |
| 341 | Define environment variable names as constants. Use `env::var` with clear error messages or sensible defaults. |
| 342 | |
| 343 | <!-- <example-env-vars> --> |
| 344 | ```rust |
| 345 | const ENDPOINT_VAR: &str = "SERVICE_ENDPOINT"; |
| 346 | const INTERVAL_VAR: &str = "POLLING_INTERVAL"; |
| 347 | |
| 348 | let endpoint = env::var(ENDPOINT_VAR) |
| 349 | .expect("SERVICE_ENDPOINT must be set"); |
| 350 | |
| 351 | let interval = env::var(INTERVAL_VAR) |
| 352 | .unwrap_or_else(|_| "10".to_string()) |
| 353 | .parse::<u64>() |
| 354 | .expect("POLLING_INTERVAL must be a valid u64"); |
| 355 | ``` |
| 356 | <!-- </example-env-vars> --> |
| 357 | |
| 358 | ### File-Based Configuration |
| 359 | |
| 360 | Support YAML and JSON configuration with validation: |
| 361 | |
| 362 | ```rust |
| 363 | impl AppConfig { |
| 364 | pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> { |
| 365 | let content = std::fs::read_to_string(&path)?; |
| 366 | let config: Self = serde_json::from_str(&content)?; |
| 367 | config.validate()?; |
| 368 | Ok(config) |
| 369 | } |
| 370 | |
| 371 | pub fn validate(&self) -> Result<()> { |
| 372 | if self.endpoint.is_empty() { |
| 373 | return Err(ServiceError::invalid_input("Endpoint must not be empty")); |
| 374 | } |
| 375 | Ok(()) |
| 376 | } |
| 377 | } |
| 378 | ``` |
| 379 | |
| 380 | ### Static Initialization |
| 381 | |
| 382 | Use `OnceLock` for thread-safe, one-time initialization of global state: |
| 383 | |
| 384 | ```rust |
| 385 | use std::sync::OnceLock; |
| 386 | |
| 387 | static CONFIG: OnceLock<AppConfig> = OnceLock::new(); |
| 388 | ``` |
| 389 | |
| 390 | ## Resilience Patterns |
| 391 | |
| 392 | ### Retry Logic |
| 393 | |
| 394 | Use `tokio_retry` with exponential backoff for transient failures: |
| 395 | |
| 396 | <!-- <example-retry> --> |
| 397 | ```rust |
| 398 | use tokio_retry::{strategy::ExponentialBackoff, Retry}; |
| 399 | use std::time::Duration; |
| 400 | |
| 401 | let retry_strategy = ExponentialBackoff::from_millis(2000) |
| 402 | .max_delay(Duration::from_secs(10)) |
| 403 | .take(5); |
| 404 | |
| 405 | let result = Retry::spawn(retry_strategy, || async { |
| 406 | client.send(&payload).await |
| 407 | }).await; |
| 408 | ``` |
| 409 | <!-- </example-retry> --> |
| 410 | |
| 411 | ## Visibility |
| 412 | |
| 413 | * Default to private. Expose only what is needed at module boundaries. |
| 414 | * Use `pub(crate)` for types shared across modules within the same crate. |
| 415 | * Mark public API types with `pub` only when they form the crate's external contract. |
| 416 | |
| 417 | ### Feature Flags |
| 418 | |
| 419 | Use Cargo feature flags for optional functionality: |
| 420 | |
| 421 | ```rust |
| 422 | pub enum Backend { |
| 423 | #[cfg(feature = "backend-a")] |
| 424 | BackendA(BackendAImpl), |
| 425 | #[cfg(feature = "backend-b")] |
| 426 | BackendB(BackendBImpl), |
| 427 | } |
| 428 | ``` |
| 429 | |
| 430 | Declare features and gate optional dependencies in `Cargo.toml`: |
| 431 | |
| 432 | ```toml |
| 433 | [features] |
| 434 | default = [] |
| 435 | backend-a = ["dep:backend-a-crate"] |
| 436 | backend-b = ["dep:backend-b-crate"] |
| 437 | ``` |
| 438 | |
| 439 | Gate heavy dependencies behind features so downstream consumers control binary size. |
| 440 | |
| 441 | ## Code Documentation |
| 442 | |
| 443 | Public and protected items require documentation comments. |
| 444 | |
| 445 | Guidelines: |
| 446 | |
| 447 | * Use `///` for item-level documentation on public types, functions, and modules. |
| 448 | * Use `//!` for module-level documentation at the top of `lib.rs` or `mod.rs`. |
| 449 | * Document parameters and return values in the description when non-obvious. |
| 450 | * Include `# Examples` sections for public API functions. |
| 451 | * Include `# Errors` sections for functions returning `Result`. |
| 452 | * Include `# Panics` sections for functions that can panic. |
| 453 | |
| 454 | <!-- <example-documentation> --> |
| 455 | ```rust |
| 456 | /// Processes the input data and returns the transformed result. |
| 457 | /// |
| 458 | /// # Arguments |
| 459 | /// |
| 460 | /// * `input` - Raw input bytes to process. |
| 461 | /// * `config` - Processing configuration. |
| 462 | /// |
| 463 | /// # Returns |
| 464 | /// |
| 465 | /// The processed output as a byte vector. |
| 466 | /// |
| 467 | /// # Errors |
| 468 | /// |
| 469 | /// Returns `ServiceError::InvalidInput` if the input cannot be parsed. |
| 470 | /// |
| 471 | /// # Examples |
| 472 | /// |
| 473 | /// ``` |
| 474 | /// let result = process(&input, &config)?; |
| 475 | /// assert!(!result.is_empty()); |
| 476 | /// ``` |
| 477 | pub fn process(input: &[u8], config: &ProcessConfig) -> Result<Vec<u8>> { |
| 478 | // ... |
| 479 | } |
| 480 | ``` |
| 481 | <!-- </example-documentation> --> |
| 482 | |
| 483 | ## Clippy and Formatting |
| 484 | |
| 485 | ### Clippy |
| 486 | |
| 487 | * Run `cargo clippy` and resolve all warnings before committing. |
| 488 | * Do not suppress Clippy lints without documented justification. |
| 489 | * Use `#[allow(...)]` on specific items rather than crate-wide `#![allow(...)]` when suppression is necessary. |
| 490 | |
| 491 | ### Formatting |
| 492 | |
| 493 | * Run `cargo fmt` before committing. |
| 494 | * Use default `rustfmt` configuration unless the project includes a `rustfmt.toml`. |
| 495 | |
| 496 | ## Additional Conventions |
| 497 | |
| 498 | * Prefer `&str` over `String` in function parameters when ownership is not needed. |
| 499 | * Use `impl Into<String>` for constructors and builders accepting string arguments. |
| 500 | * Use `Cow<'_, str>` when a function may or may not need to allocate: |
| 501 | |
| 502 | ```rust |
| 503 | use std::borrow::Cow; |
| 504 | |
| 505 | fn normalize_name(name: &str) -> Cow<'_, str> { |
| 506 | if name.contains(' ') { |
| 507 | Cow::Owned(name.replace(' ', "_")) |
| 508 | } else { |
| 509 | Cow::Borrowed(name) |
| 510 | } |
| 511 | } |
| 512 | ``` |
| 513 | |
| 514 | * Use iterators and combinators over manual loops where readability is maintained. |
| 515 | * Prefer `Vec<u8>` and slices over raw pointer manipulation. |
| 516 | |
| 517 | ## Patterns to Avoid |
| 518 | |
| 519 | * `println!` or `eprintln!` in production code (use `tracing` macros). |
| 520 | * `unwrap()` or `expect()` in production paths without compile-time guarantees. |
| 521 | * Shared mutable state without synchronization primitives (`Mutex`, `RwLock`, `OnceLock`). |
| 522 | * Blocking operations inside async contexts (use `tokio::task::spawn_blocking`). |
| 523 | * Overly broad feature sets on dependencies (minimize with `default-features = false`). |
| 524 | * Global mutable statics without `OnceLock`, `LazyLock`, or equivalent. |
| 525 | * Suppressing Clippy lints without documented justification. |
| 526 | * `unsafe` blocks without a `// SAFETY:` comment explaining the invariant. |
| 527 | |
| 528 | ## Complete Example |
| 529 | |
| 530 | Demonstrates naming, structure, error handling, async patterns, configuration, observability, and testing: |
| 531 | |
| 532 | ```rust |
| 533 | // Rust uses modules, not namespaces — see the mod declarations below. |
| 534 | |
| 535 | use std::env; |
| 536 | use std::time::Duration; |
| 537 | |
| 538 | use serde::{Deserialize, Serialize}; |
| 539 | use thiserror::Error; |
| 540 | use tokio_retry::{strategy::ExponentialBackoff, Retry}; |
| 541 | use tracing::{error, info}; |
| 542 | use tracing_subscriber::filter::EnvFilter; |
| 543 | |
| 544 | const ENDPOINT_VAR: &str = "SERVICE_ENDPOINT"; |
| 545 | const INTERVAL_VAR: &str = "POLLING_INTERVAL"; |
| 546 | |
| 547 | // --- Error --- |
| 548 | |
| 549 | pub type Result<T> = std::result::Result<T, ServiceError>; |
| 550 | |
| 551 | #[derive(Error, Debug)] |
| 552 | pub enum ServiceError { |
| 553 | #[error("Not found: {message}")] |
| 554 | NotFound { message: String }, |
| 555 | |
| 556 | #[error("Request failed: {message}")] |
| 557 | Request { message: String }, |
| 558 | |
| 559 | #[error("IO error: {0}")] |
| 560 | Io(#[from] std::io::Error), |
| 561 | |
| 562 | #[error("JSON error: {0}")] |
| 563 | Json(#[from] serde_json::Error), |
| 564 | } |
| 565 | |
| 566 | impl ServiceError { |
| 567 | pub fn not_found<S: Into<String>>(message: S) -> Self { |
| 568 | Self::NotFound { message: message.into() } |
| 569 | } |
| 570 | |
| 571 | pub fn request<S: Into<String>>(message: S) -> Self { |
| 572 | Self::Request { message: message.into() } |
| 573 | } |
| 574 | } |
| 575 | |
| 576 | // --- Config --- |
| 577 | |
| 578 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| 579 | pub struct AppConfig { |
| 580 | pub endpoint: String, |
| 581 | #[serde(default = "default_interval")] |
| 582 | pub polling_interval_secs: u64, |
| 583 | } |
| 584 | |
| 585 | fn default_interval() -> u64 { 10 } |
| 586 | |
| 587 | impl AppConfig { |
| 588 | pub fn from_env() -> Self { |
| 589 | Self { |
| 590 | endpoint: env::var(ENDPOINT_VAR) |
| 591 | .expect("SERVICE_ENDPOINT must be set"), |
| 592 | polling_interval_secs: env::var(INTERVAL_VAR) |
| 593 | .unwrap_or_else(|_| "10".to_string()) |
| 594 | .parse() |
| 595 | .expect("POLLING_INTERVAL must be a valid u64"), |
| 596 | } |
| 597 | } |
| 598 | } |
| 599 | |
| 600 | // --- Service --- |
| 601 | |
| 602 | /// Polls an endpoint and processes responses. |
| 603 | pub struct PollingService { |
| 604 | config: AppConfig, |
| 605 | client: reqwest::Client, |
| 606 | } |
| 607 | |
| 608 | impl PollingService { |
| 609 | pub fn new(config: AppConfig) -> Self { |
| 610 | Self { |
| 611 | config, |
| 612 | client: reqwest::Client::new(), |
| 613 | } |
| 614 | } |
| 615 | |
| 616 | /// Starts the polling loop. |
| 617 | /// |
| 618 | /// # Errors |
| 619 | /// |
| 620 | /// Returns `ServiceError::Request` on repeated fetch failures. |
| 621 | pub async fn run(&self) -> Result<()> { |
| 622 | let interval = Duration::from_secs(self.config.polling_interval_secs); |
| 623 | |
| 624 | loop { |
| 625 | match self.fetch_with_retry().await { |
| 626 | Ok(data) => info!(items = data.len(), "Fetched data"), |
| 627 | Err(err) => error!(?err, "Fetch failed after retries"), |
| 628 | } |
| 629 | tokio::time::sleep(interval).await; |
| 630 | } |
| 631 | } |
| 632 | |
| 633 | async fn fetch_with_retry(&self) -> Result<Vec<u8>> { |
| 634 | let strategy = ExponentialBackoff::from_millis(1000) |
| 635 | .max_delay(Duration::from_secs(8)) |
| 636 | .take(3); |
| 637 | |
| 638 | Retry::spawn(strategy, || self.fetch()).await |
| 639 | } |
| 640 | |
| 641 | async fn fetch(&self) -> Result<Vec<u8>> { |
| 642 | let response = self.client |
| 643 | .get(&self.config.endpoint) |
| 644 | .send() |
| 645 | .await |
| 646 | .map_err(|e| ServiceError::request(e.to_string()))?; |
| 647 | |
| 648 | if !response.status().is_success() { |
| 649 | return Err(ServiceError::request( |
| 650 | format!("HTTP {}", response.status()), |
| 651 | )); |
| 652 | } |
| 653 | |
| 654 | response.bytes() |
| 655 | .await |
| 656 | .map(|b| b.to_vec()) |
| 657 | .map_err(|e| ServiceError::request(e.to_string())) |
| 658 | } |
| 659 | } |
| 660 | |
| 661 | // --- Entry Point --- |
| 662 | |
| 663 | #[tokio::main] |
| 664 | async fn main() -> Result<()> { |
| 665 | tracing_subscriber::fmt() |
| 666 | .with_env_filter(EnvFilter::from_default_env()) |
| 667 | .init(); |
| 668 | |
| 669 | let config = AppConfig::from_env(); |
| 670 | info!(endpoint = %config.endpoint, "Starting service"); |
| 671 | |
| 672 | let service = PollingService::new(config); |
| 673 | service.run().await |
| 674 | } |
| 675 | ``` |