Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Get started on mission control | 1 | use serde::de::DeserializeOwned; |
| Merge empty successes across services: Ok(None) and Ok(()) read back as themselves, so every cache miss is a miss and not a 500, and toolkit failures say why | 2 | use serde::de::value::UnitDeserializer; |
| Get started on mission control | 3 | use serde::{Deserialize, Deserializer, Serialize, Serializer}; |
| 4 | ||
| 5 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 6 | #[serde(rename_all = "snake_case")] | |
| 7 | pub enum FailureCode { | |
| 8 | NotFound, | |
| 9 | Forbidden, | |
| 10 | Unauthenticated, | |
| 11 | Conflict, | |
| 12 | Invalid, | |
| 13 | /// The workspace has to pay before this can happen. | |
| 14 | PaymentRequired, | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 15 | // Billing's refusals to start compute (`reserve`), each with a message |
| 16 | // that says what to do. See `g1t_contracts::billing::ReserveArgs`. | |
| 17 | /// No plan, and no trial or pool that pays for this kind of work: a | |
| 18 | /// card check or the g1t plan is needed. | |
| 19 | NotPaid, | |
| 20 | /// The workspace's one trial is spent. | |
| 21 | TrialUsed, | |
| 22 | /// The workspace reached its spend limit or g1t's ceiling. | |
| 23 | Limit, | |
| 24 | /// Compute is paused: a spend spike waiting for an owner, or a hold. | |
| 25 | Paused, | |
| 26 | /// This month's open-source pool, or the repository's share, is spent. | |
| 27 | OssPoolEmpty, | |
| 28 | /// A sensitive change needs the person to prove it is them again: a | |
| 29 | /// recent sign-in, or their password. See `identity::Reauth`. | |
| 30 | ReauthRequired, | |
| Merge branch 'mirroring' into artifacts-mode | 31 | /// Another host g1t depends on for this did not answer. |
| 32 | Unavailable, | |
| Get started on mission control | 33 | } |
| 34 | ||
| 35 | impl FailureCode { | |
| 36 | pub fn http_status(self) -> u16 { | |
| 37 | match self { | |
| 38 | FailureCode::NotFound => 404, | |
| 39 | FailureCode::Forbidden => 403, | |
| 40 | FailureCode::Unauthenticated => 401, | |
| 41 | FailureCode::Conflict => 409, | |
| 42 | FailureCode::Invalid => 422, | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 43 | FailureCode::PaymentRequired |
| 44 | | FailureCode::NotPaid | |
| 45 | | FailureCode::TrialUsed | |
| 46 | | FailureCode::Limit | |
| 47 | | FailureCode::OssPoolEmpty => 402, | |
| 48 | FailureCode::Paused => 409, | |
| 49 | FailureCode::ReauthRequired => 403, | |
| Merge branch 'mirroring' into artifacts-mode | 50 | FailureCode::Unavailable => 503, |
| Get started on mission control | 51 | } |
| 52 | } | |
| 53 | } | |
| 54 | ||
| 55 | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 56 | pub struct Failure { | |
| 57 | pub code: FailureCode, | |
| 58 | pub message: String, | |
| 59 | } | |
| 60 | ||
| 61 | /// The result of a service call. Expected failures cross service boundaries | |
| 62 | /// as values, so callers have to handle them. | |
| 63 | /// | |
| 64 | /// On the wire: `{"ok": true, "value": …}` or `{"ok": false, "error": …}`. | |
| 65 | #[derive(Clone, Debug)] | |
| 66 | pub enum Outcome<T> { | |
| 67 | Ok(T), | |
| 68 | Fail(Failure), | |
| 69 | } | |
| 70 | ||
| 71 | impl<T> Outcome<T> { | |
| 72 | pub fn fail(code: FailureCode, message: impl Into<String>) -> Self { | |
| 73 | Outcome::Fail(Failure { | |
| 74 | code, | |
| 75 | message: message.into(), | |
| 76 | }) | |
| 77 | } | |
| 78 | ||
| 79 | pub fn into_result(self) -> Result<T, Failure> { | |
| 80 | match self { | |
| 81 | Outcome::Ok(value) => Ok(value), | |
| 82 | Outcome::Fail(failure) => Err(failure), | |
| 83 | } | |
| 84 | } | |
| 85 | } | |
| 86 | ||
| 87 | #[derive(Serialize, Deserialize)] | |
| 88 | #[serde(bound(serialize = "T: Serialize", deserialize = "T: DeserializeOwned"))] | |
| 89 | struct Wire<T> { | |
| 90 | ok: bool, | |
| 91 | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 92 | value: Option<T>, | |
| 93 | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 94 | error: Option<Failure>, | |
| 95 | } | |
| 96 | ||
| 97 | impl<T: Serialize> Serialize for Outcome<T> { | |
| 98 | fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> { | |
| 99 | match self { | |
| 100 | Outcome::Ok(value) => Wire { | |
| 101 | ok: true, | |
| 102 | value: Some(value), | |
| 103 | error: None, | |
| 104 | }, | |
| 105 | Outcome::Fail(failure) => Wire { | |
| 106 | ok: false, | |
| 107 | value: None, | |
| 108 | error: Some(failure.clone()), | |
| 109 | }, | |
| 110 | } | |
| 111 | .serialize(serializer) | |
| 112 | } | |
| 113 | } | |
| 114 | ||
| 115 | impl<'de, T: DeserializeOwned> Deserialize<'de> for Outcome<T> { | |
| 116 | fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> { | |
| 117 | let wire = Wire::<T>::deserialize(deserializer)?; | |
| 118 | match (wire.ok, wire.value, wire.error) { | |
| 119 | (true, Some(value), _) => Ok(Outcome::Ok(value)), | |
| 120 | (false, _, Some(error)) => Ok(Outcome::Fail(error)), | |
| Merge empty successes across services: Ok(None) and Ok(()) read back as themselves, so every cache miss is a miss and not a 500, and toolkit failures say why | 121 | // `"value": null`, or no value: what `Ok(None)` and `Ok(())` are |
| 122 | // written as. `Option<Option<T>>` reads a null as the outer | |
| 123 | // `None`, so the value is read from nothing instead: `None` for | |
| 124 | // an `Option`, `()` for a unit, and still malformed for a type | |
| 125 | // that needs a value. | |
| 126 | (true, None, _) => T::deserialize(UnitDeserializer::<D::Error>::new()) | |
| 127 | .map(Outcome::Ok) | |
| 128 | .map_err(|_| serde::de::Error::custom("malformed outcome: ok without a value")), | |
| Get started on mission control | 129 | _ => Err(serde::de::Error::custom("malformed outcome")), |
| 130 | } | |
| 131 | } | |
| 132 | } | |
| Merge empty successes across services: Ok(None) and Ok(()) read back as themselves, so every cache miss is a miss and not a 500, and toolkit failures say why | 133 | |
| 134 | #[cfg(test)] | |
| 135 | mod tests { | |
| 136 | use super::*; | |
| 137 | ||
| 138 | fn round_trip<T: Serialize + DeserializeOwned>(outcome: &Outcome<T>) -> Result<Outcome<T>, String> { | |
| 139 | // As a service replies (`g1t_kit::reply`) and its caller reads it | |
| 140 | // (`g1t_kit::call`). | |
| 141 | let wire = serde_json::to_string(outcome).map_err(|e| e.to_string())?; | |
| 142 | serde_json::from_str(&wire).map_err(|e| e.to_string()) | |
| 143 | } | |
| 144 | ||
| 145 | /// A cache miss is `Ok(None)`, written `{"ok":true,"value":null}`. It | |
| 146 | /// was read back as malformed, so every miss of the toolkit's cache | |
| 147 | /// (sccache's first lookup, `sccache/.sccache_check`) was a 500. | |
| 148 | #[test] | |
| 149 | fn ok_none_and_ok_unit_cross_the_wire() { | |
| 150 | assert_eq!(serde_json::to_string(&Outcome::<Option<u8>>::Ok(None)).unwrap(), r#"{"ok":true,"value":null}"#); | |
| 151 | assert!(matches!(round_trip(&Outcome::<Option<u8>>::Ok(None)), Ok(Outcome::Ok(None)))); | |
| 152 | assert!(matches!(round_trip(&Outcome::Ok(Some(7u8))), Ok(Outcome::Ok(Some(7))))); | |
| 153 | assert!(matches!(round_trip(&Outcome::Ok(())), Ok(Outcome::Ok(())))); | |
| 154 | assert!(matches!(serde_json::from_str::<Outcome<Option<u8>>>(r#"{"ok":true}"#), Ok(Outcome::Ok(None)))); | |
| 155 | let failed = round_trip(&Outcome::<Option<u8>>::fail(FailureCode::Unauthenticated, "no")); | |
| 156 | assert!(matches!(failed, Ok(Outcome::Fail(Failure { code: FailureCode::Unauthenticated, .. })))); | |
| 157 | } | |
| 158 | ||
| 159 | #[test] | |
| 160 | fn an_ok_without_the_value_it_needs_is_still_malformed() { | |
| 161 | assert!(serde_json::from_str::<Outcome<u8>>(r#"{"ok":true,"value":null}"#).is_err()); | |
| 162 | assert!(serde_json::from_str::<Outcome<String>>(r#"{"ok":true}"#).is_err()); | |
| 163 | assert!(serde_json::from_str::<Outcome<u8>>(r#"{"ok":false}"#).is_err()); | |
| 164 | } | |
| 165 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.