Skip to content

Commit 67cb93c

Browse files
fiskusclaude
andcommitted
Fix: a send timeout was classified as our bug, not the network's
Review caught a real defect, and it reintroduced the exact thing #825 added a test for. The timeout borrowed `TelemetryError::Serialize` for want of a better variant. `classify` treats anything that is not the client's own error as a refusal — sound for a payload that never left the process, wrong for a timeout, which *did* reach the network and simply got no verdict. So a slow request filed a false fault and **spent the one refusal report a run is allowed**, swallowing any genuine refusal later in the same run. The per-variant tests passed throughout, because the variant they exercised was not the one the timeout used. That is the more useful lesson than the bug: the coverage was per-error-value, and the defect was in which value got chosen. - A `SendTimeout` variant of its own, since classification follows from the variant. - `classify` is now exhaustive over our own errors as well as the client's, so a new one cannot inherit an answer by falling through — which is precisely how this happened. - The existing refusal-report test now runs both kinds of not-reaching-the-API, so the property is pinned against the shape that broke it rather than only the shape I thought of first. just lint 0, cargo fmt --check 0, 322 quilt-sync tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent e100d5c commit 67cb93c

3 files changed

Lines changed: 67 additions & 20 deletions

File tree

quilt-sync/src-tauri/src/error.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,16 @@ pub enum TelemetryError {
5050

5151
#[error("Mixpanel serialization error: {0}")]
5252
Serialize(String),
53+
54+
/// A send abandoned before the ingest API answered.
55+
///
56+
/// Its own variant rather than borrowing another, because how a failure is
57+
/// *classified* follows from its variant: a timeout reached the network and got
58+
/// no verdict, which is the user's connection and not our bug. Filed under a
59+
/// serialization error it would read as a refusal, raise a false fault, and
60+
/// spend the one refusal report a run is allowed.
61+
#[error("Mixpanel send timed out after {0}s")]
62+
SendTimeout(u64),
5363
}
5464

5565
#[derive(thiserror::Error, Debug)]

quilt-sync/src-tauri/src/telemetry.rs

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -356,20 +356,31 @@ mod tests {
356356

357357
/// And a network failure must not consume the one refusal report — otherwise
358358
/// the first flaky tunnel masks a real misconfiguration for the whole run.
359+
///
360+
/// Both kinds of not-reaching-the-API are exercised, because this exact property
361+
/// regressed once through the kind that was missing: a send timeout borrowed the
362+
/// serialization error, classified as a refusal, and spent the report.
359363
#[test]
360364
fn a_network_failure_does_not_spend_the_refusal_report() {
361-
let telemetry = Telemetry::default();
362-
363-
telemetry.report_delivery_failure_for_test(&crate::Error::from(
364-
mixpanel_rs::error::Error::ApiServerError(503),
365-
));
366-
telemetry.report_delivery_failure_for_test(&refusal());
365+
for network in [
366+
crate::Error::from(mixpanel_rs::error::Error::ApiServerError(503)),
367+
crate::Error::from(crate::error::TelemetryError::SendTimeout(10)),
368+
] {
369+
let telemetry = Telemetry::default();
370+
371+
telemetry.report_delivery_failure_for_test(&network);
372+
assert!(
373+
telemetry.reported_faults().is_empty(),
374+
"{network} is not a fault to report"
375+
);
367376

368-
assert_eq!(
369-
telemetry.reported_faults().len(),
370-
1,
371-
"the refusal still got through"
372-
);
377+
telemetry.report_delivery_failure_for_test(&refusal());
378+
assert_eq!(
379+
telemetry.reported_faults().len(),
380+
1,
381+
"the refusal still got through after {network}"
382+
);
383+
}
373384
}
374385

375386
/// Tests build with `debug_assertions` on, so this pins the branch a

quilt-sync/src-tauri/src/telemetry/mixpanel.rs

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -121,12 +121,24 @@ impl DeliveryFailure {
121121
pub fn classify(err: &crate::Error) -> Self {
122122
use mixpanel_rs::error::Error as Mp;
123123

124-
let crate::Error::Telemetry(TelemetryError::Mixpanel(err)) = err else {
125-
// Anything that is not the client's own error never reached the
126-
// network — a payload we could not serialize is not a bad connection.
124+
let crate::Error::Telemetry(err) = err else {
125+
// Nothing outside telemetry reaches this, and an unrecognised failure
126+
// is safer treated as ours than as the network's.
127127
return Self::Refused;
128128
};
129129

130+
// Exhaustive over our own errors as well as the client's, so a new one
131+
// cannot inherit an answer by falling through. That is precisely how a
132+
// timeout once read as a refusal.
133+
let err = match err {
134+
// A timeout *did* reach the network — it simply got no verdict, which
135+
// is the user's connection rather than our payload.
136+
TelemetryError::SendTimeout(_) => return Self::Unreachable,
137+
// A payload we could not even serialize never left the process.
138+
TelemetryError::Serialize(_) => return Self::Refused,
139+
TelemetryError::Mixpanel(err) => err,
140+
};
141+
130142
match err {
131143
// A verdict came back, and it was no. `ApiClientError` is what the
132144
// verbose response parses a non-`1` status into.
@@ -306,12 +318,7 @@ async fn send_batch(
306318

307319
tokio::time::timeout(SEND_TIMEOUT, mixpanel.track_batch(events))
308320
.await
309-
.map_err(|_| {
310-
TelemetryError::Serialize(format!(
311-
"send timed out after {}s",
312-
SEND_TIMEOUT.as_secs()
313-
))
314-
})??;
321+
.map_err(|_| TelemetryError::SendTimeout(SEND_TIMEOUT.as_secs()))??;
315322
}
316323
Analytics::DryRun => {
317324
for queued in &batch {
@@ -699,6 +706,25 @@ mod tests {
699706
assert_eq!(DeliveryFailure::classify(&err), DeliveryFailure::Refused);
700707
}
701708

709+
/// A timeout *did* reach the network and got no verdict, so it is the user's
710+
/// connection, not our payload.
711+
///
712+
/// This regressed once and the existing coverage did not catch it: the timeout
713+
/// borrowed the serialization error, which classifies as a refusal, so a slow
714+
/// network filed a false fault **and spent the one refusal report a run is
715+
/// allowed** — swallowing any genuine refusal later in the same run. The
716+
/// per-variant tests passed throughout, because the variant they exercised was
717+
/// not the one the timeout used.
718+
#[test]
719+
fn a_timeout_is_the_network_not_our_payload() {
720+
let err = crate::Error::from(TelemetryError::SendTimeout(10));
721+
assert_eq!(
722+
DeliveryFailure::classify(&err),
723+
DeliveryFailure::Unreachable,
724+
"a timeout must not be reported as our bug"
725+
);
726+
}
727+
702728
/// Autosync gets its **own** names rather than folding into the manual
703729
/// series. Pinned here because that was a deliberate call: adding background
704730
/// work to `package_published` would silently redefine a series already being

0 commit comments

Comments
 (0)