From d20ec050f0f238ad6b4ddc6b21802e9585098a7b Mon Sep 17 00:00:00 2001 From: ualtinok Date: Tue, 28 Jul 2026 23:00:38 +0200 Subject: [PATCH] provider-usage: add errorClass, a machine-readable reason for a degraded entry Every degraded entry looks alike on this wire. A provider nobody configured and a provider whose credential broke this morning both arrive as an entry with an error string, and the only thing telling them apart is that string -- which is prose with no stability promise, and which consumers are explicitly told not to parse. The consequence is a state change with no observable: a provider that worked yesterday and failed today moves a count by one and produces no other signal. On the current host that is 3 genuinely failing providers hidden among 25 that are unconfigured and always will be. errorClass carries the reason as a stable name derived from the producer taxonomy rather than from the message text. Classes today: credential_absent, credential_unusable, credential_rejected, no_quota_reported, upstream_failed, decode_failed. It is a String rather than an enum deliberately. The class list will grow, and on an observability surface an unrecognised value must not become a parse failure -- that would make a provider vanish from the output at exactly the moment its state changed. Consumers render an unknown class as degraded-with-unknown-reason, and a test pins that an unknown value decodes intact. Additive and non-breaking: absent on healthy entries, omitted when unset, and a producer that never sets it serializes exactly as before, which is also pinned by a test. degraded() is unchanged for existing callers; degraded_with_class() is the constructor for producers that know the class. --- crates/cortexkit-provider-usage/Cargo.toml | 2 +- crates/cortexkit-provider-usage/src/lib.rs | 117 +++++++++++++++++++++ 2 files changed, 118 insertions(+), 1 deletion(-) diff --git a/crates/cortexkit-provider-usage/Cargo.toml b/crates/cortexkit-provider-usage/Cargo.toml index c2fc221..cba7f2a 100644 --- a/crates/cortexkit-provider-usage/Cargo.toml +++ b/crates/cortexkit-provider-usage/Cargo.toml @@ -11,7 +11,7 @@ # says; this crate makes no guarantee about how a producer derived the numbers. [package] name = "cortexkit-provider-usage" -version = "0.3.0" +version = "0.4.0" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/crates/cortexkit-provider-usage/src/lib.rs b/crates/cortexkit-provider-usage/src/lib.rs index a8470bd..cf82a5e 100644 --- a/crates/cortexkit-provider-usage/src/lib.rs +++ b/crates/cortexkit-provider-usage/src/lib.rs @@ -167,6 +167,38 @@ pub struct ProviderUsage { /// truthy `error`. #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, + /// A stable, machine-readable name for *why* a degraded entry failed, + /// published beside the human-readable `error`. + /// + /// `error` is prose with no stability promise, so consumers are told not to + /// branch on it — which leaves them no way to separate failures that mean + /// something from failures that are a permanent, correct state. A host that + /// never configured a provider and a host whose credential broke this + /// morning both produce a degraded entry, and only the second is worth + /// anyone's attention. + /// + /// Classes currently produced: + /// + /// | Value | Meaning | + /// |---|---| + /// | `credential_absent` | No credential was found. Permanent and correct on a host that never configured this provider; nothing to fix. | + /// | `credential_unusable` | A credential was found but cannot be used as it stands (empty, incomplete, or refused by the credential store). Someone configured this and it needs fixing. | + /// | `credential_rejected` | The upstream rejected the credential (401/403). Usually means logging in again. | + /// | `no_quota_reported` | The credential works and the account genuinely has no quota to report. Not a failure. | + /// | `upstream_failed` | The upstream could not be reached or returned an error status. Usually transient. | + /// | `decode_failed` | The response arrived but was not the expected shape. | + /// + /// **This list will grow.** A consumer must render an unrecognised class as + /// a degraded entry with an unknown reason — never drop the entry, and + /// never fold it into an existing bucket. It is a `String` rather than an + /// enum for exactly that reason: on an observability surface, meeting an + /// unknown value must not turn into a parse failure that makes a provider + /// disappear at the moment its state changed. + /// + /// Absent on healthy entries, and absent from any producer that predates + /// this field. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub error_class: Option, } impl ProviderUsage { @@ -182,6 +214,7 @@ impl ProviderUsage { saved_resets: None, usage: Some(usage), error: None, + error_class: None, } } @@ -198,6 +231,25 @@ impl ProviderUsage { saved_resets: None, usage: None, error: Some(error.to_string()), + error_class: None, + } + } + + /// A degraded entry that also names *why* it failed. + /// + /// Prefer this over [`Self::degraded`] wherever the producer knows the + /// class: without it a consumer can only tell an unconfigured provider from + /// a broken one by reading prose it has been told not to parse. See + /// [`ProviderUsage::error_class`] for the classes and for the rule that an + /// unrecognised one must still render. + pub fn degraded_with_class( + provider: &str, + error: impl std::fmt::Display, + error_class: impl Into, + ) -> Self { + Self { + error_class: Some(error_class.into()), + ..Self::degraded(provider, error) } } } @@ -351,4 +403,69 @@ mod tests { let back: RateWindow = serde_json::from_str(&json).unwrap(); assert_eq!(back, enriched); } + + /// The field is additive: a producer that does not set it must serialize + /// exactly as before, or adding it changes every existing entry on the wire. + #[test] + fn an_entry_without_a_class_serializes_as_it_did_before_the_field_existed() { + let entry = ProviderUsage::degraded("codex", "no session: nothing configured"); + let json = serde_json::to_string(&entry).unwrap(); + + assert!(!json.contains("errorClass"), "absent class must be omitted"); + // Not vacuous: the entry really is a degraded one carrying its message, + // so this cannot pass by serializing something empty. + assert!(json.contains("\"error\":\"no session: nothing configured\"")); + + let back: ProviderUsage = serde_json::from_str(&json).unwrap(); + assert_eq!(back, entry); + assert_eq!(back.error_class, None); + } + + #[test] + fn a_class_round_trips_under_its_camel_case_wire_name() { + let entry = ProviderUsage::degraded_with_class( + "gemini", + "credential unusable: gemini creds have no refresh_token", + "credential_unusable", + ); + let json = serde_json::to_string(&entry).unwrap(); + + assert!(json.contains("\"errorClass\":\"credential_unusable\"")); + let back: ProviderUsage = serde_json::from_str(&json).unwrap(); + assert_eq!(back, entry); + } + + /// The classes are open by design, so a consumer built today must survive a + /// producer that ships a class it has never heard of. Modelling the field as + /// a `String` is what buys that: an enum would make this a parse failure, + /// and on an observability surface a parse failure means the entry vanishes + /// at the moment its state changed. + #[test] + fn an_unknown_class_decodes_rather_than_failing() { + let json = r#"{"provider":"someprovider","error":"something new","errorClass":"a_class_from_the_future"}"#; + + let entry: ProviderUsage = + serde_json::from_str(json).expect("an unrecognised class must not fail to decode"); + + assert_eq!( + entry.error_class.as_deref(), + Some("a_class_from_the_future") + ); + // The rest of the entry survives intact, so a consumer can still render + // it as degraded-with-unknown-reason rather than dropping it. + assert_eq!(entry.provider, "someprovider"); + assert_eq!(entry.error.as_deref(), Some("something new")); + } + + /// A healthy entry must never carry a class: the field's presence is itself + /// a signal, and a class on a working provider would be a contradiction a + /// consumer has to resolve. + #[test] + fn a_healthy_entry_carries_no_class() { + let entry = ProviderUsage::healthy("codex", None, "oauth", Usage::default()); + assert_eq!(entry.error_class, None); + assert!(!serde_json::to_string(&entry) + .unwrap() + .contains("errorClass")); + } }