Skip to main content

omni_dev/
github_rate_limit.rs

1//! GitHub API rate-limit resolution for the daemon's budget monitor (#1375).
2//!
3//! The engine half of the rate-limit view, mirroring the [`crate::pr_status`]
4//! engine / [`crate::daemon::services::worktrees`] adapter split: this module is
5//! pure resolution — run `gh api rate_limit`, parse the reply, compute per-resource
6//! used-percentage — and knows nothing about the daemon, the socket, or the tray.
7//! The adapter owns the poll loop and surfaces the cached snapshot.
8//!
9//! # Why this exists
10//!
11//! The daemon's worktrees PR-badge poller ([`crate::pr_status`]) shells out to `gh`,
12//! spending the same GitHub API budget as every other tool sharing the user's `gh`
13//! token. When that consumption spikes the budget silently drains to zero and
14//! rate-limits `gh` machine-wide, with no visibility until commands start failing.
15//! This module lets the daemon watch the *trend* and warn before exhaustion.
16//!
17//! # Why polling it is free
18//!
19//! GitHub documents `GET /rate_limit` as **not counting against your rate limit**
20//! (confirmed empirically: consecutive `gh api rate_limit` calls leave `core.used`
21//! flat). So this monitor adds **zero** cost to the very budget it watches — the
22//! poller can run on a fixed cadence without a budget concern of its own.
23//!
24//! # No credential enters the daemon
25//!
26//! Resolution shells out to `gh api rate_limit`, so the GitHub token stays inside
27//! `gh` where the user already put it — exactly as the PR poller does (ADR-0050),
28//! following ADR-0003's "shell out to `gh`/`git` for GitHub operations".
29
30use std::path::Path;
31use std::sync::{Mutex, PoisonError};
32
33use anyhow::{bail, Context, Result};
34use chrono::{DateTime, Utc};
35use serde::{Deserialize, Serialize};
36use serde_json::Value;
37
38/// The used-percentage at or above which a resource is treated as "high".
39///
40/// A resource over this is highlighted in `daemon status`, logged as a warning by
41/// the poller, and marked in the tray. ~80% is the point at which an approaching
42/// exhaustion is worth catching before it bites.
43pub const WARN_PERCENT: f64 = 80.0;
44
45/// The `/rate_limit` resources this monitor surfaces, in display order. `core`
46/// (REST) and `graphql` are the two the daemon's own `gh` usage spends; `search`
47/// is included because it is a common third consumer and costs nothing extra to
48/// report.
49const RESOURCE_KEYS: &[&str] = &["graphql", "core", "search"];
50
51/// One resource's budget usage, parsed from a `/rate_limit` `resources.<name>`
52/// object.
53#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
54pub struct RateLimitResource {
55    /// Requests spent in the current window.
56    pub used: u64,
57    /// The window's ceiling.
58    pub limit: u64,
59    /// Requests remaining (`limit - used`, as GitHub reports it).
60    pub remaining: u64,
61    /// `used / limit * 100`, rounded to one decimal. `0.0` when `limit` is `0`, so
62    /// a missing or zero ceiling never divides by zero or reads as "over budget".
63    pub percent: f64,
64    /// Unix epoch (seconds) at which the window resets. Rendered as `HH:MMZ` by
65    /// [`format_reset_utc`]; kept raw on the wire so machines can compute their own.
66    pub reset: i64,
67}
68
69impl RateLimitResource {
70    /// Reads one `resources.<name>` object, computing [`percent`](Self::percent).
71    /// `None` when the object is absent or missing `used`/`limit` — a partial reply
72    /// surfaces the resources it *does* carry rather than failing the whole poll.
73    fn from_value(v: &Value) -> Option<Self> {
74        let used = v.get("used").and_then(Value::as_u64)?;
75        let limit = v.get("limit").and_then(Value::as_u64)?;
76        // `remaining` is usually present; derive it defensively when it is not.
77        let remaining = v
78            .get("remaining")
79            .and_then(Value::as_u64)
80            .unwrap_or_else(|| limit.saturating_sub(used));
81        let reset = v.get("reset").and_then(Value::as_i64).unwrap_or(0);
82        Some(Self {
83            used,
84            limit,
85            remaining,
86            percent: percent_of(used, limit),
87            reset,
88        })
89    }
90
91    /// Whether this resource is at or above the [`WARN_PERCENT`] threshold.
92    #[must_use]
93    pub fn over_warn(&self) -> bool {
94        self.percent >= WARN_PERCENT
95    }
96
97    /// Builds a resource from parts, computing [`percent`](Self::percent). Used to
98    /// fold a graphql budget reading carried by a PR poll (#1389, fix 8) into the
99    /// cache without re-parsing a `/rate_limit` reply.
100    #[must_use]
101    pub fn new(used: u64, limit: u64, remaining: u64, reset: i64) -> Self {
102        Self {
103            used,
104            limit,
105            remaining,
106            percent: percent_of(used, limit),
107            reset,
108        }
109    }
110}
111
112/// `used / limit * 100`, rounded to one decimal, guarding `limit == 0` → `0.0`.
113fn percent_of(used: u64, limit: u64) -> f64 {
114    if limit == 0 {
115        return 0.0;
116    }
117    // Round to one decimal so the value is stable to serialize and compare (raw
118    // f64 division prints long tails that make the wire and tests noisy).
119    let raw = used as f64 / limit as f64 * 100.0;
120    (raw * 10.0).round() / 10.0
121}
122
123/// A parsed `gh api rate_limit` snapshot: the resources this monitor surfaces.
124///
125/// Every field is optional and skipped when absent, so the JSON on the daemon's
126/// status payload carries only what `gh` actually returned (the daemon's
127/// forward-compat contract). In practice `graphql` and `core` are always present.
128#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)]
129pub struct RateLimitSnapshot {
130    /// The GraphQL API budget — what the PR-badge poller spends.
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub graphql: Option<RateLimitResource>,
133    /// The REST (core) API budget.
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub core: Option<RateLimitResource>,
136    /// The search API budget.
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub search: Option<RateLimitResource>,
139}
140
141impl RateLimitSnapshot {
142    /// The resources in display order, each paired with its name, skipping any that
143    /// were absent from the reply.
144    fn resources(&self) -> Vec<(&'static str, RateLimitResource)> {
145        [
146            ("graphql", self.graphql),
147            ("core", self.core),
148            ("search", self.search),
149        ]
150        .into_iter()
151        .filter_map(|(name, res)| res.map(|r| (name, r)))
152        .collect()
153    }
154
155    /// The highest used-percentage across present resources, or `0.0` when empty.
156    #[must_use]
157    pub fn max_percent(&self) -> f64 {
158        self.resources()
159            .iter()
160            .map(|(_, r)| r.percent)
161            .fold(0.0_f64, f64::max)
162    }
163
164    /// Whether any present resource is at or above [`WARN_PERCENT`].
165    #[must_use]
166    pub fn over_warn(&self) -> bool {
167        self.resources().iter().any(|(_, r)| r.over_warn())
168    }
169
170    /// The one-line summary for `daemon status`, e.g.
171    /// `graphql 82% (4100/5000, resets 06:50Z) · core 3% (27/1000)`, prefixed with
172    /// `⚠ ` when any resource is over the warn threshold. Empty when no resource is
173    /// present (the caller then prints nothing).
174    #[must_use]
175    pub fn summary_line(&self) -> String {
176        let body = self
177            .resources()
178            .iter()
179            .map(|(name, r)| format_resource(name, r))
180            .collect::<Vec<_>>()
181            .join(" · ");
182        if body.is_empty() {
183            return body;
184        }
185        if self.over_warn() {
186            format!("⚠ {body}")
187        } else {
188            body
189        }
190    }
191
192    /// The compact tray label, e.g. `github: graphql 82% · core 3%`, with a
193    /// trailing `⚠` when over the warn threshold. Empty when no resource is present.
194    #[must_use]
195    pub fn tray_label(&self) -> String {
196        let resources = self.resources();
197        if resources.is_empty() {
198            return String::new();
199        }
200        let body = resources
201            .iter()
202            .map(|(name, r)| format!("{name} {}%", trim_percent(r.percent)))
203            .collect::<Vec<_>>()
204            .join(" · ");
205        if self.over_warn() {
206            format!("github: {body} ⚠")
207        } else {
208            format!("github: {body}")
209        }
210    }
211}
212
213/// Formats one resource as `graphql 82% (4100/5000, resets 06:50Z)`.
214fn format_resource(name: &str, r: &RateLimitResource) -> String {
215    format!(
216        "{name} {}% ({}/{}, resets {})",
217        trim_percent(r.percent),
218        r.used,
219        r.limit,
220        format_reset_utc(r.reset)
221    )
222}
223
224/// Renders a percentage without a trailing `.0` (so `82.0` prints `82`, `81.5`
225/// prints `81.5`) — the usual budget reading is whole-number and the decimal is
226/// noise.
227fn trim_percent(percent: f64) -> String {
228    if (percent.fract()).abs() < f64::EPSILON {
229        format!("{}", percent as i64)
230    } else {
231        format!("{percent}")
232    }
233}
234
235/// Formats a unix epoch as a `HH:MMZ` UTC clock time, e.g. `06:50Z`. A zero or
236/// unparseable epoch renders `??:??Z` rather than a misleading `00:00Z`.
237#[must_use]
238pub fn format_reset_utc(epoch: i64) -> String {
239    match DateTime::<Utc>::from_timestamp(epoch, 0) {
240        Some(dt) if epoch > 0 => dt.format("%H:%MZ").to_string(),
241        _ => "??:??Z".to_string(),
242    }
243}
244
245/// Parses a `gh api rate_limit` reply into a [`RateLimitSnapshot`]. Best-effort per
246/// resource: a resource absent or malformed is simply omitted rather than sinking
247/// the whole snapshot.
248fn parse_rate_limit(body: &Value) -> RateLimitSnapshot {
249    let resources = body.get("resources");
250    let read = |key: &str| -> Option<RateLimitResource> {
251        resources
252            .and_then(|r| r.get(key))
253            .and_then(RateLimitResource::from_value)
254    };
255    debug_assert!(RESOURCE_KEYS.contains(&"graphql"));
256    RateLimitSnapshot {
257        graphql: read("graphql"),
258        core: read("core"),
259        search: read("search"),
260    }
261}
262
263/// Runs one `gh api rate_limit` call against `bin`. **Blocking** — callers must be
264/// on a blocking thread, never an async worker. Mirrors
265/// [`crate::pr_status`]'s `run_gh_graphql`.
266fn run_gh_rate_limit(bin: &Path) -> Result<Value> {
267    let output = crate::github_metrics::run_gh(bin, ["api", "rate_limit"], "api rate_limit", None)
268        .with_context(|| {
269            format!(
270                "failed to run {} (is the GitHub CLI installed?)",
271                bin.display()
272            )
273        })?;
274    if !output.status.success() {
275        let stderr = String::from_utf8_lossy(&output.stderr);
276        bail!("gh api rate_limit failed: {}", stderr.trim());
277    }
278    serde_json::from_slice(&output.stdout).context("gh api rate_limit returned invalid JSON")
279}
280
281/// Resolves the current rate-limit snapshot in **one** `gh api rate_limit` call,
282/// using the `gh` at `bin`.
283///
284/// The binary is a parameter rather than resolved here so callers read the
285/// environment **once** (the poller does it at spawn) and so tests inject a stub
286/// without mutating the process environment — the same discipline as
287/// [`crate::pr_status::resolve_with`]. Reuse [`crate::pr_status::resolve_gh_binary`]
288/// to obtain `bin`.
289///
290/// **Blocking** — run on a blocking thread. A missing, unauthenticated, or failing
291/// `gh` yields `Err`, which the poller logs and shrugs off (keeping the last good
292/// snapshot); querying `/rate_limit` spends nothing, so this call never affects the
293/// budget it reports.
294pub fn resolve_rate_limit_with(bin: &Path) -> Result<RateLimitSnapshot> {
295    let body = run_gh_rate_limit(bin)?;
296    Ok(parse_rate_limit(&body))
297}
298
299/// The poller-written, status/menu-read rate-limit snapshot cache.
300///
301/// A plain `std::Mutex<Option<..>>`: writes come from the poll loop, reads from the
302/// status op and the tray menu build. The lock is never held across an `.await` —
303/// every method takes it, finishes, and drops it. Empty until the first poll lands,
304/// in which case `daemon status` simply carries no `github_rate_limit` field.
305#[derive(Debug, Default)]
306pub struct RateLimitCache {
307    snapshot: Mutex<Option<RateLimitSnapshot>>,
308}
309
310impl RateLimitCache {
311    /// An empty cache.
312    #[must_use]
313    pub fn new() -> Self {
314        Self::default()
315    }
316
317    /// The latest snapshot, or `None` before the first successful poll.
318    #[must_use]
319    pub fn get(&self) -> Option<RateLimitSnapshot> {
320        *self.lock()
321    }
322
323    /// Stores `next`, returning whether it differs from the previous snapshot — the
324    /// caller uses the bool only to decide whether to log a transition, never to
325    /// bump the tree change-notify (rate limit is not tree topology).
326    pub fn replace(&self, next: RateLimitSnapshot) -> bool {
327        let mut guard = self.lock();
328        let changed = *guard != Some(next);
329        *guard = Some(next);
330        changed
331    }
332
333    /// Folds a `graphql` budget reading carried by a PR poll (#1389, fix 8) into the
334    /// cache, updating **only** the graphql resource and preserving the `core` /
335    /// `search` readings from the standalone `/rate_limit` poller. Returns whether
336    /// the snapshot changed.
337    ///
338    /// Every PR poll ships the graphql budget for free (the query's own `rateLimit`
339    /// block), so this keeps that reading fresh between — or instead of — standalone
340    /// polls, which lets the standalone poller idle when nothing is being watched
341    /// (fix 8b) without the graphql figure going stale while polling is active.
342    pub fn observe_graphql(&self, graphql: RateLimitResource) -> bool {
343        let mut guard = self.lock();
344        let mut snap = (*guard).unwrap_or_default();
345        let changed = snap.graphql != Some(graphql);
346        snap.graphql = Some(graphql);
347        *guard = Some(snap);
348        changed
349    }
350
351    /// Poison-tolerant lock: a panicking holder must not wedge the monitor, which is
352    /// best-effort decoration.
353    fn lock(&self) -> std::sync::MutexGuard<'_, Option<RateLimitSnapshot>> {
354        self.snapshot.lock().unwrap_or_else(PoisonError::into_inner)
355    }
356}
357
358#[cfg(test)]
359// `float_cmp`: the percentages are rounded to one decimal by `percent_of`, so the
360// expected literals are bit-identical to the computed values — an exact `==` is
361// correct here, not the usual float-equality hazard.
362#[allow(clippy::unwrap_used, clippy::expect_used, clippy::float_cmp)]
363mod tests {
364    use super::*;
365    use crate::test_support::shim::{retry_on_etxtbsy, shim_lock, write_exec_script};
366    use serde_json::json;
367    use std::path::PathBuf;
368    use std::sync::MutexGuard;
369
370    /// A realistic `gh api rate_limit` reply body.
371    fn sample_body() -> Value {
372        json!({
373            "resources": {
374                "core": {"limit": 5000, "used": 27, "remaining": 4973, "reset": 1_700_000_000_i64},
375                "graphql": {"limit": 5000, "used": 4100, "remaining": 900, "reset": 1_700_003_000_i64},
376                "search": {"limit": 30, "used": 3, "remaining": 27, "reset": 1_700_000_060_i64},
377            },
378            // The deprecated top-level `rate` alias of core — ignored.
379            "rate": {"limit": 5000, "used": 27, "remaining": 4973, "reset": 1_700_000_000_i64}
380        })
381    }
382
383    // --- Parsing & percentage math ---
384
385    #[test]
386    fn parse_reads_every_resource_and_computes_percent() {
387        let snap = parse_rate_limit(&sample_body());
388        let graphql = snap.graphql.expect("graphql present");
389        assert_eq!(graphql.used, 4100);
390        assert_eq!(graphql.limit, 5000);
391        assert_eq!(graphql.remaining, 900);
392        assert_eq!(graphql.percent, 82.0);
393        assert_eq!(graphql.reset, 1_700_003_000);
394        let core = snap.core.expect("core present");
395        assert_eq!(core.percent, 0.5);
396        assert!(snap.search.is_some());
397    }
398
399    #[test]
400    fn parse_tolerates_a_missing_search_resource() {
401        let body = json!({"resources": {
402            "core": {"limit": 5000, "used": 27, "remaining": 4973, "reset": 1},
403            "graphql": {"limit": 5000, "used": 10, "remaining": 4990, "reset": 1},
404        }});
405        let snap = parse_rate_limit(&body);
406        assert!(snap.graphql.is_some());
407        assert!(snap.core.is_some());
408        assert!(snap.search.is_none());
409    }
410
411    #[test]
412    fn parse_tolerates_a_missing_resources_block() {
413        let snap = parse_rate_limit(&json!({}));
414        assert_eq!(snap, RateLimitSnapshot::default());
415    }
416
417    #[test]
418    fn parse_derives_remaining_when_absent() {
419        let body = json!({"resources": {"core": {"limit": 100, "used": 40}}});
420        let core = parse_rate_limit(&body).core.expect("core present");
421        assert_eq!(core.remaining, 60);
422        assert_eq!(core.percent, 40.0);
423    }
424
425    #[test]
426    fn percent_guards_a_zero_limit() {
427        assert_eq!(percent_of(0, 0), 0.0);
428        assert_eq!(percent_of(5, 0), 0.0);
429        assert_eq!(percent_of(1, 3), 33.3);
430    }
431
432    // --- Warn threshold ---
433
434    #[test]
435    fn over_warn_fires_only_at_or_above_the_threshold() {
436        let res = |used: u64| RateLimitResource {
437            used,
438            limit: 100,
439            remaining: 100 - used,
440            percent: percent_of(used, 100),
441            reset: 0,
442        };
443        assert!(!res(79).over_warn());
444        assert!(res(80).over_warn());
445        assert!(res(95).over_warn());
446
447        let snap = RateLimitSnapshot {
448            core: Some(res(10)),
449            graphql: Some(res(85)),
450            search: None,
451        };
452        assert!(snap.over_warn());
453        assert_eq!(snap.max_percent(), 85.0);
454    }
455
456    // --- Formatting ---
457
458    #[test]
459    fn format_reset_utc_renders_clock_time_or_a_placeholder() {
460        // 1700000000 = 2023-11-14T22:13:20Z.
461        assert_eq!(format_reset_utc(1_700_000_000), "22:13Z");
462        assert_eq!(format_reset_utc(0), "??:??Z");
463        assert_eq!(format_reset_utc(-5), "??:??Z");
464    }
465
466    #[test]
467    fn summary_line_lists_resources_and_marks_the_warn_case() {
468        let snap = parse_rate_limit(&sample_body());
469        let line = snap.summary_line();
470        assert!(line.contains("graphql 82% (4100/5000, resets"), "{line}");
471        assert!(line.contains("core 0.5% (27/5000"), "{line}");
472        // graphql is at 82% ≥ 80, so the whole line is flagged.
473        assert!(line.starts_with("⚠ "), "{line}");
474    }
475
476    #[test]
477    fn summary_line_omits_the_marker_below_threshold() {
478        let body = json!({"resources": {
479            "graphql": {"limit": 5000, "used": 10, "remaining": 4990, "reset": 1_700_000_000_i64},
480            "core": {"limit": 5000, "used": 27, "remaining": 4973, "reset": 1_700_000_000_i64},
481        }});
482        let line = parse_rate_limit(&body).summary_line();
483        assert!(!line.starts_with('⚠'), "{line}");
484        assert!(line.starts_with("graphql 0.2%"), "{line}");
485    }
486
487    #[test]
488    fn summary_line_is_empty_without_resources() {
489        assert!(RateLimitSnapshot::default().summary_line().is_empty());
490        assert!(RateLimitSnapshot::default().tray_label().is_empty());
491    }
492
493    #[test]
494    fn tray_label_is_compact_and_marks_the_warn_case() {
495        let snap = parse_rate_limit(&sample_body());
496        let label = snap.tray_label();
497        assert!(label.starts_with("github: graphql 82%"), "{label}");
498        assert!(label.ends_with('⚠'), "{label}");
499    }
500
501    #[test]
502    fn tray_label_omits_the_marker_below_threshold() {
503        let body = json!({"resources": {
504            "graphql": {"limit": 5000, "used": 10, "remaining": 4990, "reset": 1},
505            "core": {"limit": 5000, "used": 27, "remaining": 4973, "reset": 1},
506        }});
507        let label = parse_rate_limit(&body).tray_label();
508        assert_eq!(label, "github: graphql 0.2% · core 0.5%", "{label}");
509        assert!(!label.contains('⚠'), "{label}");
510    }
511
512    // --- Cache ---
513
514    #[test]
515    fn cache_get_and_replace_report_changes() {
516        let cache = RateLimitCache::new();
517        assert!(cache.get().is_none());
518        let a = parse_rate_limit(&sample_body());
519        // First write is a change.
520        assert!(cache.replace(a));
521        assert_eq!(cache.get(), Some(a));
522        // An identical write is not.
523        assert!(!cache.replace(a));
524        // A different snapshot is.
525        let b = parse_rate_limit(&json!({"resources": {
526            "graphql": {"limit": 5000, "used": 4200, "remaining": 800, "reset": 1}
527        }}));
528        assert!(cache.replace(b));
529    }
530
531    #[test]
532    fn observe_graphql_updates_only_graphql_and_preserves_core_search() {
533        // #1389, fix 8: a PR poll folds its graphql budget in without clobbering the
534        // `core`/`search` readings the standalone poller supplied.
535        let cache = RateLimitCache::new();
536        cache.replace(RateLimitSnapshot {
537            graphql: Some(RateLimitResource::new(10, 5000, 4990, 0)),
538            core: Some(RateLimitResource::new(3, 5000, 4997, 0)),
539            search: Some(RateLimitResource::new(0, 30, 30, 0)),
540        });
541        assert!(cache.observe_graphql(RateLimitResource::new(45, 5000, 4955, 0)));
542        let snap = cache.get().unwrap();
543        assert_eq!(snap.graphql.unwrap().used, 45, "graphql updated");
544        assert_eq!(snap.core.unwrap().used, 3, "core preserved");
545        assert_eq!(snap.search.unwrap().used, 0, "search preserved");
546        // Idempotent: the same reading reports no change.
547        assert!(!cache.observe_graphql(RateLimitResource::new(45, 5000, 4955, 0)));
548        // Into an empty cache it seeds just graphql (core/search stay absent).
549        let empty = RateLimitCache::new();
550        assert!(empty.observe_graphql(RateLimitResource::new(1, 5000, 4999, 0)));
551        assert!(empty.get().unwrap().core.is_none());
552    }
553
554    // --- resolve_rate_limit_with: the degradation contract ---
555    //
556    // A missing, unauthenticated, or failing `gh` must surface an error to the
557    // poller (which keeps the last good snapshot) and never panic or hang. Mirrors
558    // `pr_status`'s `fake_gh` shim tests.
559
560    /// Writes an executable stub standing in for `gh`, printing `stdout` and exiting
561    /// `code`. Returns the shim serialisation lock alongside the path (hold it until
562    /// the exec is done); pair the exec with [`retry_on_etxtbsy`].
563    fn fake_gh(dir: &Path, stdout: &str, code: i32) -> (PathBuf, MutexGuard<'static, ()>) {
564        let guard = shim_lock();
565        let path = dir.join("fake-gh");
566        write_exec_script(
567            &path,
568            &format!("#!/bin/sh\ncat <<'JSON'\n{stdout}\nJSON\nexit {code}\n"),
569        );
570        (path, guard)
571    }
572
573    #[test]
574    fn resolve_errors_when_gh_is_missing() {
575        let err = resolve_rate_limit_with(Path::new("/no/such/gh/xyzzy")).unwrap_err();
576        let msg = format!("{err:#}");
577        assert!(msg.contains("failed to run"), "{msg}");
578        assert!(msg.contains("GitHub CLI"), "{msg}");
579    }
580
581    #[test]
582    fn resolve_errors_on_a_nonzero_exit() {
583        let dir = tempfile::tempdir().unwrap();
584        let (bin, _shim) = fake_gh(dir.path(), "", 1);
585        let err = retry_on_etxtbsy(|| resolve_rate_limit_with(&bin)).unwrap_err();
586        assert!(
587            format!("{err:#}").contains("gh api rate_limit failed"),
588            "{err:#}"
589        );
590    }
591
592    #[test]
593    fn resolve_errors_on_unparseable_output() {
594        let dir = tempfile::tempdir().unwrap();
595        let (bin, _shim) = fake_gh(dir.path(), "not json at all", 0);
596        let err = retry_on_etxtbsy(|| resolve_rate_limit_with(&bin)).unwrap_err();
597        assert!(format!("{err:#}").contains("invalid JSON"), "{err:#}");
598    }
599
600    #[test]
601    fn resolve_reads_a_real_reply_end_to_end() {
602        let dir = tempfile::tempdir().unwrap();
603        let (bin, _shim) = fake_gh(dir.path(), &sample_body().to_string(), 0);
604        let snap = retry_on_etxtbsy(|| resolve_rate_limit_with(&bin)).unwrap();
605        assert_eq!(snap.graphql.unwrap().percent, 82.0);
606        assert_eq!(snap.core.unwrap().used, 27);
607    }
608}