mirror of
https://github.com/azaion/autopilot.git
synced 2026-06-22 16:41:10 +00:00
[AZ-641] [AZ-642] [AZ-644] mavlink transport + codec + mission pull
Lands the second batch under epic AZ-626's implementation plan.
mavlink_layer (AZ-641 + AZ-642):
- Hand-rolled MAVLink v2 codec covering the §7.7 surface: HEARTBEAT,
SYS_STATUS, SET_MODE, ATTITUDE, GLOBAL_POSITION_INT, MISSION_* (7),
COMMAND_LONG, COMMAND_ACK, EXTENDED_SYS_STATE, STATUSTEXT (17 total).
- Streaming decoder demuxes arbitrary-sized byte arrivals, drops malformed
frames with typed parse-error counters (crc/truncated/unknown_id/seq_gap),
and surfaces sequence gaps without hard-failing the link.
- Encoder tracks the per-link tx_seq counter and applies the MAVLink v2
trailing-zero payload truncation rule.
- UDP and POSIX-serial transports behind a single async Transport trait;
the run loop owns transport open with bounded exponential backoff
(2 s serial / 5 s UDP cap) and a tokio::select! per-link read+write
loop.
- 1 Hz outbound HEARTBEAT scheduler + inbound-heartbeat watchdog that
fires LinkUp / LinkLost on a broadcast channel and feeds health detail
(connected, last_heartbeat_age_ms, signing_enabled, parse_errors).
mission_client (AZ-644):
- HTTPS GET /missions/{id} over rustls (no OpenSSL on the airframe).
- Bundled JSON Schema (crates/shared/contracts/mission-schema.json,
draft-07, additionalProperties:false) validates every response;
schema-invalid bodies surface as FetchError::SchemaInvalid with a
1 KiB sample of the raw body for offline analysis.
- Transient failures (timeout, 5xx, 429) retry with bounded exponential
backoff up to MissionClientOptions.max_attempts (default 5); permanent
failures (4xx, malformed URL) abort immediately.
- Health surface mirrors AC-1's contract: last_fetch_ts,
fetch_errors_total, schema_version, connection_state.
Caught and fixed before commit (NOT a code-review finding — caught by
the unit test that hand-computed CRC("123456789")): the hand-rolled
X.25 CRC accumulator was operating in u16 throughout. The MAVLink C
reference declares `tmp` as uint8_t, which silently truncates the
shifted-in bits. Round-trip tests passed (encoder and decoder shared
the bug); a real MAVLink peer would have rejected every frame. Fixed
by mirroring the C reference: `let mut tmp: u8 = …; tmp ^= tmp.wrapping_shl(4);`.
Added a regression test asserting CRC("123456789") == 0x6F91 against
pymavlink's reference value (NOT the textbook 0x29B1 — MAVLink uses a
byte-wise variant, not the bit-reflected CCITT).
AC verification (full detail in
_docs/03_implementation/batch_02_cycle1_report.md):
AZ-641: AC-1 + AC-3 + AC-4 verified via UDP loopback integration tests;
AC-2 (serial) requires a socat pty pair and runs in the SITL/CI
tier (test exists as #[ignore]-marked stub).
AZ-642: AC-1 + AC-2 + AC-3 verified via exhaustive codec round-trip and
decoder negative-path tests; AC-4 (SITL round-trip) requires
ArduPilot SITL — the CRC fix above means the codec is now
wire-correct, ready for the sitl-conformance Woodpecker stage.
AZ-644: all four ACs verified via wiremock-driven integration tests.
Workspace gates green:
- cargo check --workspace clean
- cargo check --workspace --no-default-features clean
- cargo fmt --all -- --check clean
- cargo clippy --workspace --all-targets -- -D warnings clean
- cargo test --workspace pass (1 expected ignore)
Layering invariants from module-layout.md hold: mavlink_layer and
mission_client are Layer 2 actors importing only `shared`; no sibling
Layer-2 imports; MavlinkHandle implements shared::contracts::MavlinkSink.
Jira: AZ-641, AZ-642, AZ-644 transitioned To Do → In Progress at batch
start; the matching In Testing transitions follow this commit.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -11,3 +11,13 @@ authors.workspace = true
|
||||
shared = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
jsonschema = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
wiremock = { workspace = true }
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync", "time", "io-util", "net", "signal", "test-util"] }
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
//! REST client to the external `missions` API.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use reqwest::{header, Client, StatusCode};
|
||||
use serde_json::Value;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::internal::retry::ExponentialBackoff;
|
||||
use crate::internal::schema::{validate, SchemaError};
|
||||
use crate::{FetchError, MissionClientOptions};
|
||||
|
||||
/// HTTPS client wrapper. One instance per `MissionClient`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HttpClient {
|
||||
client: Client,
|
||||
endpoint: String,
|
||||
bearer: Option<String>,
|
||||
}
|
||||
|
||||
impl HttpClient {
|
||||
pub fn new(opts: &MissionClientOptions) -> Result<Self, FetchError> {
|
||||
let client = Client::builder()
|
||||
.timeout(opts.request_timeout)
|
||||
.connect_timeout(opts.connect_timeout)
|
||||
.user_agent(format!("autopilot/{}", env!("CARGO_PKG_VERSION")))
|
||||
.build()
|
||||
.map_err(|e| FetchError::Internal(format!("reqwest client build: {e}")))?;
|
||||
Ok(Self {
|
||||
client,
|
||||
endpoint: opts.endpoint.clone(),
|
||||
bearer: opts.bearer_token.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Single HTTP call — no retry. The caller (with backoff) decides what to do.
|
||||
async fn get_once(&self, mission_id: &str) -> Result<String, RawFetchError> {
|
||||
let url = format!(
|
||||
"{}/missions/{}",
|
||||
self.endpoint.trim_end_matches('/'),
|
||||
mission_id
|
||||
);
|
||||
let mut req = self
|
||||
.client
|
||||
.get(&url)
|
||||
.header(header::ACCEPT, "application/json");
|
||||
if let Some(tok) = &self.bearer {
|
||||
req = req.bearer_auth(tok);
|
||||
}
|
||||
let resp = req.send().await.map_err(|e| {
|
||||
if e.is_timeout() || e.is_connect() {
|
||||
RawFetchError::Transient(e.to_string())
|
||||
} else if e.is_request() || e.is_builder() {
|
||||
RawFetchError::Permanent(e.to_string())
|
||||
} else {
|
||||
RawFetchError::Transient(e.to_string())
|
||||
}
|
||||
})?;
|
||||
|
||||
let status = resp.status();
|
||||
let body = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| RawFetchError::Transient(format!("read body: {e}")))?;
|
||||
|
||||
if status.is_success() {
|
||||
return Ok(body);
|
||||
}
|
||||
// Retry on 5xx (and treat 429 as transient too).
|
||||
if status.is_server_error() || status == StatusCode::TOO_MANY_REQUESTS {
|
||||
return Err(RawFetchError::Transient(format!(
|
||||
"http {status}: {}",
|
||||
preview(&body)
|
||||
)));
|
||||
}
|
||||
Err(RawFetchError::Permanent(format!(
|
||||
"http {status}: {}",
|
||||
preview(&body)
|
||||
)))
|
||||
}
|
||||
|
||||
/// Fetch + validate + return the typed JSON value (caller deserialises into
|
||||
/// the typed model). Implements bounded exponential backoff on transient
|
||||
/// failures only; permanent failures abort immediately.
|
||||
pub async fn pull_mission_raw(
|
||||
&self,
|
||||
mission_id: &str,
|
||||
opts: &MissionClientOptions,
|
||||
) -> Result<Value, FetchError> {
|
||||
let mut backoff = ExponentialBackoff::new(opts.backoff_base, opts.backoff_cap);
|
||||
|
||||
for attempt in 1..=opts.max_attempts {
|
||||
match self.get_once(mission_id).await {
|
||||
Ok(body) => {
|
||||
let value = validate(&body).map_err(|e| match e {
|
||||
SchemaError::Invalid { messages, sample } => {
|
||||
FetchError::SchemaInvalid { messages, sample }
|
||||
}
|
||||
SchemaError::ParseJson { message, sample } => FetchError::SchemaInvalid {
|
||||
messages: vec![message],
|
||||
sample,
|
||||
},
|
||||
})?;
|
||||
return Ok(value);
|
||||
}
|
||||
Err(RawFetchError::Permanent(reason)) => {
|
||||
return Err(FetchError::Permanent(reason));
|
||||
}
|
||||
Err(RawFetchError::Transient(reason)) => {
|
||||
warn!(
|
||||
component = "mission_client",
|
||||
attempt,
|
||||
max = opts.max_attempts,
|
||||
reason = %reason,
|
||||
"transient fetch failure"
|
||||
);
|
||||
if attempt < opts.max_attempts {
|
||||
tokio::time::sleep(backoff.next_delay()).await;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(FetchError::MaxRetriesExceeded {
|
||||
attempts: opts.max_attempts,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum RawFetchError {
|
||||
Transient(String),
|
||||
Permanent(String),
|
||||
}
|
||||
|
||||
fn preview(body: &str) -> String {
|
||||
let cap = 256;
|
||||
if body.len() <= cap {
|
||||
body.to_owned()
|
||||
} else {
|
||||
format!("{}…", &body[..cap])
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // Used for diagnostic output and by future health detail.
|
||||
pub fn default_request_timeout() -> Duration {
|
||||
Duration::from_secs(5)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod missions_api;
|
||||
pub mod retry;
|
||||
pub mod schema;
|
||||
@@ -0,0 +1,43 @@
|
||||
//! Local copy of the bounded exponential-backoff helper.
|
||||
//!
|
||||
//! Duplicated from `mavlink_layer::internal::retry` rather than promoted to
|
||||
//! `shared`; the two callsites have different defaults and retry policies and
|
||||
//! the file is small enough that the SRP cost is lower than the cross-crate
|
||||
//! coupling.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ExponentialBackoff {
|
||||
base: Duration,
|
||||
cap: Duration,
|
||||
attempt: u32,
|
||||
}
|
||||
|
||||
impl ExponentialBackoff {
|
||||
pub fn new(base: Duration, cap: Duration) -> Self {
|
||||
assert!(base > Duration::ZERO, "backoff base must be positive");
|
||||
assert!(cap >= base, "backoff cap must be >= base");
|
||||
Self {
|
||||
base,
|
||||
cap,
|
||||
attempt: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn next_delay(&mut self) -> Duration {
|
||||
let exp = self.attempt.min(31);
|
||||
let delay = self
|
||||
.base
|
||||
.checked_mul(1u32 << exp)
|
||||
.unwrap_or(self.cap)
|
||||
.min(self.cap);
|
||||
self.attempt = self.attempt.saturating_add(1);
|
||||
delay
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // surfaced through tests + future health detail
|
||||
pub fn attempts(&self) -> u32 {
|
||||
self.attempt
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
//! Mission JSON-schema validation.
|
||||
//!
|
||||
//! Bundled copy of `shared/contracts/mission-schema.json` is compiled into the
|
||||
//! binary via `include_str!`. The shared file is the wire contract co-owned
|
||||
//! with the external `missions` repo (see `architecture.md §8 Q5`).
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use jsonschema::JSONSchema;
|
||||
use serde_json::Value;
|
||||
|
||||
/// Bundled schema content (canonical wire contract).
|
||||
pub const SCHEMA_BYTES: &str = include_str!("../../../../shared/contracts/mission-schema.json");
|
||||
|
||||
fn compiled() -> &'static JSONSchema {
|
||||
static SCHEMA: OnceLock<JSONSchema> = OnceLock::new();
|
||||
SCHEMA.get_or_init(|| {
|
||||
let schema_value: Value = serde_json::from_str(SCHEMA_BYTES)
|
||||
.expect("bundled mission-schema.json must be valid JSON at compile time");
|
||||
JSONSchema::options()
|
||||
.compile(&schema_value)
|
||||
.expect("bundled mission-schema.json must compile as JSON Schema")
|
||||
})
|
||||
}
|
||||
|
||||
/// Validate raw JSON bytes against the bundled schema.
|
||||
///
|
||||
/// Returns the parsed JSON `Value` on success so callers can re-deserialise
|
||||
/// it into the typed `Mission` without re-parsing.
|
||||
pub fn validate(raw: &str) -> Result<Value, SchemaError> {
|
||||
let value: Value = serde_json::from_str(raw).map_err(|e| SchemaError::ParseJson {
|
||||
message: e.to_string(),
|
||||
sample: sample_of(raw),
|
||||
})?;
|
||||
|
||||
let messages: Option<Vec<String>> = {
|
||||
let result = compiled().validate(&value);
|
||||
result
|
||||
.err()
|
||||
.map(|errors| errors.map(|e| format!("{e}")).collect())
|
||||
};
|
||||
|
||||
if let Some(messages) = messages {
|
||||
return Err(SchemaError::Invalid {
|
||||
messages,
|
||||
sample: sample_of(raw),
|
||||
});
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
const SAMPLE_CAP: usize = 1024;
|
||||
|
||||
fn sample_of(raw: &str) -> String {
|
||||
if raw.len() <= SAMPLE_CAP {
|
||||
raw.to_owned()
|
||||
} else {
|
||||
let mut s = raw[..SAMPLE_CAP].to_owned();
|
||||
s.push_str(" …<truncated>");
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SchemaError {
|
||||
#[error("response was not valid JSON: {message}")]
|
||||
ParseJson { message: String, sample: String },
|
||||
#[error("response failed schema validation: {}", messages.join("; "))]
|
||||
Invalid {
|
||||
messages: Vec<String>,
|
||||
sample: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const GOOD: &str = r#"{
|
||||
"mission_id": "11111111-2222-3333-4444-555555555555",
|
||||
"schema_version": "1.0.0",
|
||||
"items": [
|
||||
{ "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", "kind": "waypoint",
|
||||
"at": { "latitude": 49.1, "longitude": 31.2, "altitude_m": 100.0 } }
|
||||
],
|
||||
"geofences": [],
|
||||
"return_point": { "latitude": 49.0, "longitude": 31.0, "altitude_m": 0.0 }
|
||||
}"#;
|
||||
|
||||
#[test]
|
||||
fn good_mission_validates() {
|
||||
// Act
|
||||
let r = validate(GOOD);
|
||||
|
||||
// Assert
|
||||
assert!(r.is_ok(), "validation failed: {:?}", r.err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_required_field_fails() {
|
||||
// Arrange
|
||||
let bad = GOOD.replace("\"mission_id\"", "\"mission_oops\"");
|
||||
|
||||
// Act
|
||||
let r = validate(&bad);
|
||||
|
||||
// Assert
|
||||
assert!(matches!(r, Err(SchemaError::Invalid { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_json_fails() {
|
||||
// Act
|
||||
let r = validate("{ not json");
|
||||
|
||||
// Assert
|
||||
assert!(matches!(r, Err(SchemaError::ParseJson { .. })));
|
||||
}
|
||||
}
|
||||
@@ -1,46 +1,189 @@
|
||||
//! `mission_client` — REST client for the `missions` API.
|
||||
//! `mission_client` — REST client to the external `missions` API.
|
||||
//!
|
||||
//! Real implementation lands in:
|
||||
//! - AZ-644 `mission_client_pull_and_schema`
|
||||
//! - AZ-645 `mission_client_waypoint_post`
|
||||
//! - AZ-646 `mission_client_mapobjects_pull`
|
||||
//! - AZ-647 `mission_client_mapobjects_push`
|
||||
//! Public surface (per `module-layout.md`): [`MissionClient`],
|
||||
//! [`MissionClientHandle`], the typed [`Mission`] DTO, [`FetchError`], and
|
||||
//! [`MissionClientOptions`].
|
||||
//!
|
||||
//! Real implementation tasks: AZ-644 (pull + schema, this file), AZ-645
|
||||
//! (middle-waypoint POST), AZ-646 (mapobjects pull), AZ-647 (mapobjects push).
|
||||
|
||||
mod internal;
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use shared::error::{AutopilotError, Result};
|
||||
use shared::health::ComponentHealth;
|
||||
use shared::models::mapobject::MapObjectsBundle;
|
||||
use shared::models::mission::{Coordinate, MissionItem};
|
||||
use shared::models::mission::{Coordinate, Geofence, MissionItem};
|
||||
use uuid::Uuid;
|
||||
|
||||
use internal::missions_api::HttpClient;
|
||||
|
||||
const NAME: &str = "mission_client";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MissionClient {
|
||||
pub endpoint: String,
|
||||
/// Mission DTO returned by `pull_mission`. Shape matches the JSON wire schema
|
||||
/// in `shared/contracts/mission-schema.json`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Mission {
|
||||
pub mission_id: Uuid,
|
||||
pub schema_version: String,
|
||||
pub items: Vec<MissionItem>,
|
||||
pub geofences: Vec<Geofence>,
|
||||
pub return_point: Coordinate,
|
||||
}
|
||||
|
||||
impl MissionClient {
|
||||
pub fn new(endpoint: String) -> Self {
|
||||
Self { endpoint }
|
||||
}
|
||||
/// Errors surfaced by `MissionClientHandle::pull_mission`.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum FetchError {
|
||||
/// JSON body did not match the bundled `mission-schema`. Includes a
|
||||
/// size-capped sample of the raw body for offline analysis.
|
||||
#[error("mission schema invalid: {}", messages.join("; "))]
|
||||
SchemaInvalid {
|
||||
messages: Vec<String>,
|
||||
sample: String,
|
||||
},
|
||||
/// Non-retryable HTTP or transport-level error (4xx, malformed URL, etc.).
|
||||
#[error("permanent fetch failure: {0}")]
|
||||
Permanent(String),
|
||||
/// Retried up to `max_attempts` without success.
|
||||
#[error("max retries exceeded after {attempts} attempts")]
|
||||
MaxRetriesExceeded { attempts: u32 },
|
||||
/// Local bug (deserialisation after schema validation succeeded, etc.).
|
||||
#[error("internal error: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
pub fn handle(&self) -> MissionClientHandle {
|
||||
MissionClientHandle {
|
||||
endpoint: self.endpoint.clone(),
|
||||
impl From<FetchError> for AutopilotError {
|
||||
fn from(e: FetchError) -> Self {
|
||||
match e {
|
||||
FetchError::SchemaInvalid { messages, .. } => {
|
||||
AutopilotError::Validation(messages.join("; "))
|
||||
}
|
||||
FetchError::Permanent(s) => AutopilotError::Network(s),
|
||||
FetchError::MaxRetriesExceeded { attempts } => {
|
||||
AutopilotError::Network(format!("max retries exceeded after {attempts} attempts"))
|
||||
}
|
||||
FetchError::Internal(s) => AutopilotError::Internal(s),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tunables for the missions-API client. AZ-644 §NFR defaults: 5 attempts,
|
||||
/// 200 ms base / 5 s cap, 5 s startup-fetch budget.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MissionClientOptions {
|
||||
pub endpoint: String,
|
||||
pub bearer_token: Option<String>,
|
||||
pub max_attempts: u32,
|
||||
pub backoff_base: Duration,
|
||||
pub backoff_cap: Duration,
|
||||
pub request_timeout: Duration,
|
||||
pub connect_timeout: Duration,
|
||||
}
|
||||
|
||||
impl MissionClientOptions {
|
||||
pub fn new(endpoint: impl Into<String>) -> Self {
|
||||
Self {
|
||||
endpoint: endpoint.into(),
|
||||
bearer_token: None,
|
||||
max_attempts: 5,
|
||||
backoff_base: Duration::from_millis(200),
|
||||
backoff_cap: Duration::from_secs(5),
|
||||
request_timeout: Duration::from_secs(5),
|
||||
connect_timeout: Duration::from_secs(2),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct ClientState {
|
||||
last_fetch_unix_s: AtomicU64,
|
||||
fetch_errors_total: AtomicU64,
|
||||
last_schema_version: std::sync::Mutex<Option<String>>,
|
||||
last_connection_state: std::sync::Mutex<ConnectionState>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
enum ConnectionState {
|
||||
#[default]
|
||||
Unknown,
|
||||
Ok,
|
||||
Error,
|
||||
}
|
||||
|
||||
impl ConnectionState {
|
||||
fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Unknown => "unknown",
|
||||
Self::Ok => "ok",
|
||||
Self::Error => "error",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Public client. Build once at startup and pass the [`MissionClientHandle`]
|
||||
/// to other components.
|
||||
#[derive(Debug)]
|
||||
pub struct MissionClient {
|
||||
options: MissionClientOptions,
|
||||
http: HttpClient,
|
||||
state: Arc<ClientState>,
|
||||
}
|
||||
|
||||
impl MissionClient {
|
||||
pub fn new(options: MissionClientOptions) -> std::result::Result<Self, FetchError> {
|
||||
let http = HttpClient::new(&options)?;
|
||||
Ok(Self {
|
||||
options,
|
||||
http,
|
||||
state: Arc::new(ClientState::default()),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn handle(&self) -> MissionClientHandle {
|
||||
MissionClientHandle {
|
||||
options: self.options.clone(),
|
||||
http: self.http.clone(),
|
||||
state: self.state.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clonable handle. Each clone shares the same HTTP client and counters.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MissionClientHandle {
|
||||
#[allow(dead_code)]
|
||||
endpoint: String,
|
||||
options: MissionClientOptions,
|
||||
http: HttpClient,
|
||||
state: Arc<ClientState>,
|
||||
}
|
||||
|
||||
impl MissionClientHandle {
|
||||
pub async fn pull_mission(&self, _mission_id: &str) -> Result<Vec<MissionItem>> {
|
||||
Err(AutopilotError::NotImplemented(
|
||||
"mission_client::pull_mission (AZ-644)",
|
||||
))
|
||||
/// Fetch + validate a mission by id. Implements bounded exponential
|
||||
/// backoff and rejects schema-invalid responses without a silent downcast.
|
||||
pub async fn pull_mission(&self, mission_id: &str) -> std::result::Result<Mission, FetchError> {
|
||||
match self.http.pull_mission_raw(mission_id, &self.options).await {
|
||||
Ok(value) => {
|
||||
let mission: Mission = serde_json::from_value(value)
|
||||
.map_err(|e| FetchError::Internal(format!("deserialise mission: {e}")))?;
|
||||
self.state
|
||||
.last_fetch_unix_s
|
||||
.store(now_unix_s(), Ordering::Relaxed);
|
||||
*self.state.last_schema_version.lock().unwrap() =
|
||||
Some(mission.schema_version.clone());
|
||||
*self.state.last_connection_state.lock().unwrap() = ConnectionState::Ok;
|
||||
Ok(mission)
|
||||
}
|
||||
Err(e) => {
|
||||
self.state
|
||||
.fetch_errors_total
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
*self.state.last_connection_state.lock().unwrap() = ConnectionState::Error;
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn post_middle_waypoint(&self, _mission_id: &str, _at: Coordinate) -> Result<()> {
|
||||
@@ -62,17 +205,52 @@ impl MissionClientHandle {
|
||||
}
|
||||
|
||||
pub fn health(&self) -> ComponentHealth {
|
||||
ComponentHealth::disabled(NAME)
|
||||
let conn = *self.state.last_connection_state.lock().unwrap();
|
||||
let last_fetch = self.state.last_fetch_unix_s.load(Ordering::Relaxed);
|
||||
let errors = self.state.fetch_errors_total.load(Ordering::Relaxed);
|
||||
let schema_version = self
|
||||
.state
|
||||
.last_schema_version
|
||||
.lock()
|
||||
.unwrap()
|
||||
.clone()
|
||||
.unwrap_or_else(|| "none".to_owned());
|
||||
let detail = format!(
|
||||
"last_fetch_ts={} fetch_errors_total={} schema_version={} connection_state={}",
|
||||
if last_fetch == 0 {
|
||||
"none".into()
|
||||
} else {
|
||||
last_fetch.to_string()
|
||||
},
|
||||
errors,
|
||||
schema_version,
|
||||
conn.label(),
|
||||
);
|
||||
match conn {
|
||||
ConnectionState::Ok => ComponentHealth::green(NAME),
|
||||
ConnectionState::Error => ComponentHealth::red(NAME, detail),
|
||||
ConnectionState::Unknown => ComponentHealth::yellow(NAME, detail),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn now_unix_s() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn it_compiles() {
|
||||
let h = MissionClient::new("http://127.0.0.1:8443".into()).handle();
|
||||
assert_eq!(h.health().level, shared::health::HealthLevel::Disabled);
|
||||
fn fetch_error_maps_to_autopilot_error() {
|
||||
let e: AutopilotError = FetchError::Permanent("boom".into()).into();
|
||||
match e {
|
||||
AutopilotError::Network(s) => assert!(s.contains("boom")),
|
||||
other => panic!("expected Network, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
//! AZ-644 integration tests driven by `wiremock`.
|
||||
//!
|
||||
//! Coverage:
|
||||
//! - AC-1: happy-path fetch returns `Ok(Mission)` + health reflects connection_state="ok"
|
||||
//! - AC-2: schema-invalid response returns `Err(SchemaInvalid)` with a sample
|
||||
//! - AC-3: transient 503 → 200 sequence retries within budget
|
||||
//! - AC-4: 5 consecutive 503s → `Err(MaxRetriesExceeded)` and health red
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use shared::health::HealthLevel;
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
use mission_client::{FetchError, MissionClient, MissionClientOptions};
|
||||
|
||||
fn good_mission_body(mission_id: &str) -> String {
|
||||
serde_json::json!({
|
||||
"mission_id": mission_id,
|
||||
"schema_version": "1.0.0",
|
||||
"items": [
|
||||
{ "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", "kind": "waypoint",
|
||||
"at": { "latitude": 49.1, "longitude": 31.2, "altitude_m": 100.0 } }
|
||||
],
|
||||
"geofences": [],
|
||||
"return_point": { "latitude": 49.0, "longitude": 31.0, "altitude_m": 0.0 }
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn options_for(mock: &MockServer, attempts: u32) -> MissionClientOptions {
|
||||
let mut o = MissionClientOptions::new(mock.uri());
|
||||
o.max_attempts = attempts;
|
||||
o.backoff_base = Duration::from_millis(10);
|
||||
o.backoff_cap = Duration::from_millis(50);
|
||||
o.request_timeout = Duration::from_secs(2);
|
||||
o.connect_timeout = Duration::from_secs(1);
|
||||
o
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ac1_happy_path_fetch() {
|
||||
// Arrange
|
||||
let mock = MockServer::start().await;
|
||||
let mission_id = "11111111-2222-3333-4444-555555555555";
|
||||
Mock::given(method("GET"))
|
||||
.and(path(format!("/missions/{mission_id}")))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(good_mission_body(mission_id)))
|
||||
.mount(&mock)
|
||||
.await;
|
||||
let client = MissionClient::new(options_for(&mock, 5)).expect("client builds");
|
||||
let h = client.handle();
|
||||
|
||||
// Act
|
||||
let mission = h.pull_mission(mission_id).await.expect("happy fetch");
|
||||
|
||||
// Assert
|
||||
assert_eq!(mission.mission_id.to_string(), mission_id);
|
||||
assert_eq!(mission.schema_version, "1.0.0");
|
||||
let health = h.health();
|
||||
assert_eq!(health.level, HealthLevel::Green);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ac2_schema_invalid_is_rejected() {
|
||||
// Arrange: HTTP 200 but the body is missing the required `mission_id`.
|
||||
let mock = MockServer::start().await;
|
||||
let bad_body = serde_json::json!({
|
||||
"schema_version": "1.0.0",
|
||||
"items": [
|
||||
{ "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", "kind": "waypoint",
|
||||
"at": { "latitude": 49.1, "longitude": 31.2, "altitude_m": 100.0 } }
|
||||
],
|
||||
"geofences": [],
|
||||
"return_point": { "latitude": 49.0, "longitude": 31.0, "altitude_m": 0.0 }
|
||||
})
|
||||
.to_string();
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/missions/M1"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(bad_body.clone()))
|
||||
.mount(&mock)
|
||||
.await;
|
||||
let client = MissionClient::new(options_for(&mock, 5)).expect("client builds");
|
||||
let h = client.handle();
|
||||
|
||||
// Act
|
||||
let err = h.pull_mission("M1").await.unwrap_err();
|
||||
|
||||
// Assert
|
||||
match err {
|
||||
FetchError::SchemaInvalid { messages, sample } => {
|
||||
assert!(messages.iter().any(|m| m.contains("mission_id")));
|
||||
assert!(!sample.is_empty());
|
||||
}
|
||||
other => panic!("expected SchemaInvalid, got {other:?}"),
|
||||
}
|
||||
let health = h.health();
|
||||
assert_eq!(health.level, HealthLevel::Red);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ac3_transient_failure_retries_within_budget() {
|
||||
// Arrange: first two requests return 503, third returns 200.
|
||||
let mock = MockServer::start().await;
|
||||
let mission_id = "22222222-3333-4444-5555-666666666666";
|
||||
Mock::given(method("GET"))
|
||||
.and(path(format!("/missions/{mission_id}")))
|
||||
.respond_with(ResponseTemplate::new(503))
|
||||
.up_to_n_times(2)
|
||||
.mount(&mock)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path(format!("/missions/{mission_id}")))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(good_mission_body(mission_id)))
|
||||
.mount(&mock)
|
||||
.await;
|
||||
let client = MissionClient::new(options_for(&mock, 5)).expect("client builds");
|
||||
let h = client.handle();
|
||||
|
||||
// Act
|
||||
let mission = h.pull_mission(mission_id).await.expect("retry succeeds");
|
||||
|
||||
// Assert
|
||||
assert_eq!(mission.mission_id.to_string(), mission_id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ac4_cap_exhaustion_returns_max_retries() {
|
||||
// Arrange: every request returns 503; we configure 3 attempts to keep the test fast.
|
||||
let mock = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/missions/M-cap"))
|
||||
.respond_with(ResponseTemplate::new(503))
|
||||
.mount(&mock)
|
||||
.await;
|
||||
let client = MissionClient::new(options_for(&mock, 3)).expect("client builds");
|
||||
let h = client.handle();
|
||||
|
||||
// Act
|
||||
let err = h.pull_mission("M-cap").await.unwrap_err();
|
||||
|
||||
// Assert
|
||||
match err {
|
||||
FetchError::MaxRetriesExceeded { attempts } => assert_eq!(attempts, 3),
|
||||
other => panic!("expected MaxRetriesExceeded, got {other:?}"),
|
||||
}
|
||||
let health = h.health();
|
||||
assert_eq!(health.level, HealthLevel::Red);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn permanent_client_error_does_not_retry() {
|
||||
// Arrange: 404 should be permanent (no retry).
|
||||
let mock = MockServer::start().await;
|
||||
let scoped_mock = Mock::given(method("GET"))
|
||||
.and(path("/missions/M-perm"))
|
||||
.respond_with(ResponseTemplate::new(404).set_body_string("not found"))
|
||||
.expect(1)
|
||||
.mount_as_scoped(&mock)
|
||||
.await;
|
||||
let client = MissionClient::new(options_for(&mock, 5)).expect("client builds");
|
||||
let h = client.handle();
|
||||
|
||||
// Act
|
||||
let err = h.pull_mission("M-perm").await.unwrap_err();
|
||||
|
||||
// Assert
|
||||
assert!(matches!(err, FetchError::Permanent(_)));
|
||||
drop(scoped_mock); // sanity-asserts the `.expect(1)` count was honored
|
||||
}
|
||||
Reference in New Issue
Block a user