Skip to main content

onetaskgraph_github_projects/
accounting.rs

1//! What a session of requests to GitHub cost, counted rather than argued about.
2//!
3//! Nothing here decides anything: it records what left this crate and adds it up. It
4//! exists because a query strategy cannot be chosen between without measuring what each
5//! one costs.
6//!
7//! # What one record carries
8//!
9//! One [`Request`] per outgoing HTTP request. A GraphQL request is named by the document
10//! it sent, read out of [`graphql::DOCUMENTS`] rather than from
11//! a second list of names, and carries that document's worst-case node count under the
12//! bindings that request actually sent — [`node_count`], the same
13//! offline calculation `tests/node_count.rs` holds every document to, never a second
14//! arithmetic. A REST request sends no document and has no node count, so it names the
15//! endpoint it addressed instead. Both record whether they read or wrote, how they ended,
16//! and the rate-limit facts that response's own headers carried.
17//!
18//! **Two quantities of GitHub's, kept apart by name.** `nodeCount` is the most nodes *one
19//! query may return*, checked per query; it is what [`Call::Document`] carries.
20//! `cost` is rate-limit points, metered per hour across everything one credential does; it
21//! is what [`Spend`] is in. A document well under the node limit says nothing about the
22//! second.
23//!
24//! # How a session's spend is arrived at, and what it is not
25//!
26//! Per budget, accumulated per call, from whatever that call itself makes attributable:
27//!
28//! - **[`Budget::Rest`] is metered in requests,** so a call is its own measure and is
29//!   attributed one request ([`Basis::Counted`]).
30//! - **[`Budget::Graphql`] is metered in points.** Where the request was shaped so GitHub
31//!   reports its own `cost` — a document selecting `rateLimit { cost }` — that is what is
32//!   attributed ([`Basis::Reported`]). Otherwise this repository's stated cost model
33//!   applies: **GitHub charges at least one point for any call, so one point is
34//!   attributed** ([`Basis::Modelled`]), and the report says how much of the total came
35//!   that way. That is a **lower bound** and the accounting says so rather than implying a
36//!   measurement: a call over a large connection really costs more, and no document this
37//!   source sends today asks GitHub what.
38//! - **A rate-limited refusal is attributed nothing** ([`Basis::NotRun`]), because a
39//!   request GitHub refused for a rate limit did not run — the same reading of a refusal
40//!   that makes retrying one safe in [`GitHubProjectsSource::graphql`](crate::GitHubProjectsSource).
41//!
42//! **What a session spent is never inferred by differencing a shared counter.** This
43//! account is shared and rate-limited, and other work draws on the same budgets in the same
44//! window, so an allowance that fell by sixty while this session made ten calls measures
45//! the account rather than the session. The report gives that movement anyway — it is worth
46//! seeing — and says on its face that it is the account's and not this session's.
47//!
48//! # Where a reader finds the report
49//!
50//! [`Session::report`] renders one from a snapshot, and the credentialed lane in
51//! `tests/live.rs` prints it at the end of every run, passed or failed — from a `Drop`, so
52//! that the run whose cost is most worth reading, the one that broke, is not the run that
53//! skips it. It carries no credential, no token, no issue body and no board content: a call
54//! is named by a document description this crate wrote or by an [`Endpoint`], which is a
55//! path template rather than the URL a run built, and everything else in it is a number.
56
57use std::collections::BTreeMap;
58use std::fmt::Write as _;
59use std::sync::Mutex;
60
61use serde_json::Value;
62
63pub use reqwest::StatusCode;
64
65use crate::{Limiter, Variables, graphql, largest_page_sizes, node_count};
66
67/// Which of GitHub's two separately metered budgets a request drew on.
68///
69/// They are two because GitHub meters them separately, and a single total would hide which
70/// one is close to exhaustion.
71#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
72pub enum Budget {
73    /// The GraphQL API, metered in points.
74    Graphql,
75    /// The REST API, metered in requests.
76    Rest,
77}
78
79impl Budget {
80    /// This budget's name, as GitHub's own `x-ratelimit-resource` header spells it.
81    #[must_use]
82    pub const fn name(self) -> &'static str {
83        match self {
84            Self::Graphql => "graphql",
85            Self::Rest => "rest",
86        }
87    }
88    /// What GitHub meters this budget in.
89    #[must_use]
90    pub const fn unit(self) -> &'static str {
91        match self {
92            Self::Graphql => "points",
93            Self::Rest => "requests",
94        }
95    }
96}
97
98/// Whether a request read or wrote.
99#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
100pub enum Mode {
101    /// It asked for something.
102    Read,
103    /// It changed something.
104    Write,
105}
106
107impl Mode {
108    /// What a GraphQL document does.
109    ///
110    /// Every mutation this source sends creates content and no query does, so the keyword
111    /// is the whole of the question — the same test [`crate::GitHubProjectsSource`] paces
112    /// its own writes by.
113    #[must_use]
114    pub fn of_document(document: &str) -> Self {
115        if crate::is_mutation(document) {
116            Self::Write
117        } else {
118            Self::Read
119        }
120    }
121    /// This mode's name in a report.
122    #[must_use]
123    pub const fn name(self) -> &'static str {
124        match self {
125            Self::Read => "read",
126            Self::Write => "write",
127        }
128    }
129}
130
131/// The HTTP methods a REST call to GitHub is made with.
132///
133/// A closed set rather than a string: a record cannot then carry a method that is not one,
134/// and a misspelling is refused by [`Method::parse`] where it happens rather than quietly
135/// counted as a write. It is this crate's own enum rather than an HTTP client's, so which
136/// client a caller sends its own requests with stays the caller's business.
137#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
138pub enum Method {
139    /// `GET`.
140    Get,
141    /// `HEAD`.
142    Head,
143    /// `POST`.
144    Post,
145    /// `PUT`.
146    Put,
147    /// `PATCH`.
148    Patch,
149    /// `DELETE`.
150    Delete,
151}
152
153impl Method {
154    /// The method `name` spells, whatever its case, or `None` when it spells none of them.
155    #[must_use]
156    pub fn parse(name: &str) -> Option<Self> {
157        Some(match name.trim().to_ascii_uppercase().as_str() {
158            "GET" => Self::Get,
159            "HEAD" => Self::Head,
160            "POST" => Self::Post,
161            "PUT" => Self::Put,
162            "PATCH" => Self::Patch,
163            "DELETE" => Self::Delete,
164            _ => return None,
165        })
166    }
167    /// Its name, as HTTP spells it.
168    #[must_use]
169    pub const fn name(self) -> &'static str {
170        match self {
171            Self::Get => "GET",
172            Self::Head => "HEAD",
173            Self::Post => "POST",
174            Self::Put => "PUT",
175            Self::Patch => "PATCH",
176            Self::Delete => "DELETE",
177        }
178    }
179    /// Whether it reads or writes.
180    #[must_use]
181    pub const fn mode(self) -> Mode {
182        match self {
183            Self::Get | Self::Head => Mode::Read,
184            Self::Post | Self::Put | Self::Patch | Self::Delete => Mode::Write,
185        }
186    }
187}
188
189/// How one request ended.
190#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
191pub enum Outcome {
192    /// GitHub answered with what was asked for.
193    Answered,
194    /// GitHub did not answer it, for a reason that is not a rate limit — including a
195    /// request that never reached GitHub at all, which carries no headers to read.
196    Refused,
197    /// A rate limiter refused it, so it never ran.
198    RateLimited,
199}
200
201impl Outcome {
202    /// How a response ended, read the way this source's own limiter reads one.
203    ///
204    /// `budget_exhausted` is whether `x-ratelimit-remaining` was exactly `0`, which
205    /// *explains* a failing response rather than making a successful one fail. A GraphQL
206    /// success carrying `errors` is a refusal only its caller can rule on, so this answers
207    /// [`Outcome::Answered`] for one and the caller narrows it.
208    ///
209    /// The status is a [`StatusCode`] rather than a number, so a status HTTP has no room
210    /// for cannot be asked about at all — and an HTTP client has already parsed one for
211    /// every response it hands back, so nothing is asked of a caller that it did not have.
212    #[must_use]
213    // llmlint: ignore[invalid_states_unrepresentable] `budget_exhausted` is one header read as the yes-or-no it is — whether `x-ratelimit-remaining` was exactly `0` — so both of its values are meaningful and there is no third state a type could forbid. It is also the argument [`Limiter::classify`] already takes, on the line below, so giving this one wrapper its own spelling would mean two vocabularies for one header rather than one.
214    pub fn of_response(status: StatusCode, budget_exhausted: bool, body: &str) -> Self {
215        if Limiter::classify(status, budget_exhausted, body).is_some() {
216            return Self::RateLimited;
217        }
218        if status.is_success() {
219            Self::Answered
220        } else {
221            Self::Refused
222        }
223    }
224    /// This outcome's name in a report.
225    #[must_use]
226    pub const fn name(self) -> &'static str {
227        match self {
228            Self::Answered => "answered",
229            Self::Refused => "refused",
230            Self::RateLimited => "rate-limited",
231        }
232    }
233}
234
235/// The rate-limit facts one response's own headers carried.
236///
237/// Every figure is optional because every one of them is absent from some real response: a
238/// request that never reached GitHub has no headers at all, and a refusal from an
239/// intermediary carries whichever of them that intermediary felt like carrying. An absent
240/// figure is reported as unknown rather than guessed at.
241///
242/// **These are observations of one response, so the only ways to have one are to observe a
243/// response ([`RateLimit::read`]) or to have observed nothing ([`RateLimit::default`]).**
244/// The fields are read through the accessors below rather than assembled: a hand-built set
245/// could say the account had more remaining than its whole allowance, or name a resource
246/// GitHub would never name, and a report built on it would be a measurement of nothing.
247#[derive(Debug, Clone, Default, PartialEq, Eq)]
248pub struct RateLimit {
249    /// `x-ratelimit-limit`: the whole allowance for this budget's window.
250    limit: Option<u64>,
251    /// `x-ratelimit-remaining`: what was left of it when GitHub answered.
252    remaining: Option<u64>,
253    /// `x-ratelimit-used`: what the *account* had spent, which is not what this session
254    /// spent — other work shares the budget.
255    used: Option<u64>,
256    /// `x-ratelimit-reset`: the Unix second the allowance comes back.
257    reset: Option<u64>,
258    /// `x-ratelimit-resource`: which budget GitHub says these figures are about.
259    resource: Option<String>,
260}
261
262impl RateLimit {
263    /// Read the five headers GitHub carries a budget's state in.
264    ///
265    /// `header` is given a lower-case header name and answers that response's value for
266    /// it. Taking a lookup rather than a header map is what keeps the HTTP client this
267    /// crate happens to use out of its public interface, so a caller sending its own
268    /// requests with its own client records into the same accounting.
269    #[must_use]
270    pub fn read(header: impl Fn(&str) -> Option<String>) -> Self {
271        let number = |name: &str| {
272            header(name)
273                .as_deref()
274                .map(str::trim)
275                .and_then(|value| value.parse::<u64>().ok())
276        };
277        // The three allowance figures are one fact and are read as one. A response saying
278        // more was left of a budget than the whole of it, or more of it used than it holds,
279        // cannot be true of any account — and a report built on it would be a measurement of
280        // nothing, which is what the type documentation above refuses. So a set that cannot
281        // all be true is a budget state this response did not carry: dropped **together**,
282        // because which of the three is the wrong one is not knowable from here, and
283        // reported as unknown rather than repaired into a figure nothing observed. `reset`
284        // and `resource` are independent of them and survive. `Allowance::read` in
285        // `onetaskgraph-live` refuses the same impossibility where the gate reads it.
286        let (limit, remaining, used) = (
287            number("x-ratelimit-limit"),
288            number("x-ratelimit-remaining"),
289            number("x-ratelimit-used"),
290        );
291        let over_the_whole = |figure: Option<u64>| match (figure, limit) {
292            (Some(figure), Some(limit)) => figure > limit,
293            _ => false,
294        };
295        // And the same impossibility one step on, which neither figure shows on its own:
296        // `used` and `remaining` are two views of one allowance — what the window has spent
297        // and what is left of it — so between them they cannot exceed the whole. A response
298        // saying 4,000 used and 4,000 left of 5,000 has each figure inside the limit and
299        // still cannot be true of any account.
300        //
301        // **Only that direction is refused, and a sum falling short of the whole is kept
302        // deliberately.** It accounts for less of the budget than exists rather than for
303        // more of it than could, which no arithmetic forbids and which a response really
304        // carries: the loopback board this crate is tested against answers `remaining: 0`
305        // beside a `used` naming what that session itself spent, wherever it stands in for a
306        // budget somebody else exhausted. Dropping those three would report the budget as
307        // unknown, which is a worse answer than a short one — and it could not reach what
308        // the report says this SESSION spent in any case, because that figure is attributed
309        // per call and is never differenced out of these.
310        let more_than_the_whole_between_them = match (used, remaining, limit) {
311            (Some(used), Some(remaining), Some(limit)) => used.saturating_add(remaining) > limit,
312            _ => false,
313        };
314        let readable = !over_the_whole(remaining)
315            && !over_the_whole(used)
316            && !more_than_the_whole_between_them;
317        Self {
318            limit: limit.filter(|_| readable),
319            remaining: remaining.filter(|_| readable),
320            used: used.filter(|_| readable),
321            reset: number("x-ratelimit-reset"),
322            // The one field here that is not a number, so the one that could carry a third
323            // party's arbitrary bytes into a value this crate hands back. GitHub names these
324            // `core`, `graphql`, `search`, `integration_manifest`; anything that is not
325            // spelled like one is dropped rather than stored.
326            resource: header("x-ratelimit-resource")
327                .map(|value| value.trim().to_owned())
328                .filter(|value| is_resource_name(value)),
329        }
330    }
331    /// The whole allowance for this budget's window, as this response reported it.
332    #[must_use]
333    pub const fn limit(&self) -> Option<u64> {
334        self.limit
335    }
336    /// What was left of it when GitHub answered.
337    #[must_use]
338    pub const fn remaining(&self) -> Option<u64> {
339        self.remaining
340    }
341    /// What the **account** had spent — not what this session spent, because other work
342    /// draws on the same budget in the same window.
343    #[must_use]
344    pub const fn used_by_the_account(&self) -> Option<u64> {
345        self.used
346    }
347    /// The Unix second the allowance comes back.
348    #[must_use]
349    pub const fn reset(&self) -> Option<u64> {
350        self.reset
351    }
352    /// Which budget GitHub said these figures were about, when it named one it spells.
353    #[must_use]
354    pub fn resource(&self) -> Option<&str> {
355        self.resource.as_deref()
356    }
357    /// Whether these headers say the budget is exactly spent, which is what
358    /// [`Outcome::of_response`] reads.
359    #[must_use]
360    pub fn exhausted(&self) -> bool {
361        self.remaining == Some(0)
362    }
363}
364
365/// Whether a value is spelled like one of GitHub's rate-limit resource names.
366///
367/// ASCII letters, digits, underscores and hyphens, and short. It is a shape rather than a
368/// list because GitHub adds resources — `code_search` arrived after `search` — and a name
369/// this does not recognise should be reported as GitHub sent it rather than dropped for
370/// being new.
371fn is_resource_name(value: &str) -> bool {
372    !value.is_empty()
373        && value.len() <= 64
374        && value
375            .bytes()
376            .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-')
377}
378
379/// Where one call's attributed spend came from.
380///
381/// It is on the record rather than folded into the number so a report can say how much of
382/// a session's total is GitHub's own figure and how much is this repository's model.
383#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
384pub enum Basis {
385    /// The budget is metered in requests, so the call is its own measure.
386    Counted,
387    /// GitHub reported this call's own cost, because the request asked it to.
388    Reported,
389    /// This repository's stated cost model: one point, GitHub's documented minimum for any
390    /// call, which is a lower bound rather than a measurement.
391    Modelled,
392    /// A rate limiter refused the request, so it never ran and is attributed nothing.
393    NotRun,
394}
395
396impl Basis {
397    /// This basis in a report, as a phrase that says what the figure is worth.
398    #[must_use]
399    pub const fn name(self) -> &'static str {
400        match self {
401            Self::Counted => "counted",
402            Self::Reported => "reported by GitHub",
403            Self::Modelled => "this repository's one-point-per-call lower bound",
404            Self::NotRun => "not run",
405        }
406    }
407}
408
409/// What one call is attributed against its budget, and where that figure came from.
410///
411/// **The amount and the basis are one fact, so they are settled together and read apart.**
412/// Three of the four bases fix the amount outright — a call against a budget metered in
413/// requests is one request, the model's lower bound is one point, and a request a rate
414/// limiter refused never ran and is nothing — and only [`Basis::Reported`] carries a figure
415/// of its own, GitHub's. Constructing the pair field by field would let a report say a call
416/// GitHub never ran spent forty points, which is a measurement of nothing; the four
417/// constructors below are the only ways to have one, and each is the invariant for its own
418/// basis.
419#[derive(Debug, Clone, Copy, PartialEq, Eq)]
420pub struct Spend {
421    amount: u64,
422    basis: Basis,
423}
424
425impl Spend {
426    /// One request against a budget metered in requests.
427    const fn counted() -> Self {
428        Self {
429            amount: 1,
430            basis: Basis::Counted,
431        }
432    }
433    /// What GitHub itself reported this call cost.
434    const fn reported(cost: u64) -> Self {
435        Self {
436            amount: cost,
437            basis: Basis::Reported,
438        }
439    }
440    /// This repository's lower bound: one point, GitHub's documented minimum for any call.
441    const fn modelled() -> Self {
442        Self {
443            amount: 1,
444            basis: Basis::Modelled,
445        }
446    }
447    /// Nothing, because a rate limiter refused the request and it never ran.
448    const fn not_run() -> Self {
449        Self {
450            amount: 0,
451            basis: Basis::NotRun,
452        }
453    }
454    /// The amount, in that budget's own unit.
455    #[must_use]
456    pub const fn amount(self) -> u64 {
457        self.amount
458    }
459    /// What makes it that amount.
460    #[must_use]
461    pub const fn basis(self) -> Basis {
462        self.basis
463    }
464}
465
466/// A REST endpoint, spelled the way GitHub's own documentation spells one.
467///
468/// `GET /repos/{owner}/{repo}/labels`: a method and a path template whose segments are
469/// literals or `{placeholder}`s, never the URL a run built from it. That is what makes two
470/// runs' reports compare line for line, and it is why this is a type with one constructor
471/// rather than a string a caller fills in — a record holding
472/// `https://api.github.com/repos/octo-org/board/labels?per_page=100` would put a board's
473/// name, and whatever else a query string carried, into a report that promises to carry
474/// neither.
475///
476/// **What it rules on, and what it cannot.** [`Endpoint::parse`] refuses a host, a query
477/// string, a fragment, whitespace, a byte outside the small set a path template is spelled
478/// from, and anything long — every way an addressed URL differs in *shape* from a template.
479/// It cannot tell a template's literal segment from a filled-in one, because
480/// `/repos/octo-org/board/labels` is spelled exactly like a template of literals; keeping
481/// the placeholders unfilled is the caller's, and the reason `Request::rest`'s own
482/// documentation says to pass the template.
483#[derive(Debug, Clone, PartialEq, Eq)]
484pub struct Endpoint {
485    method: Method,
486    path: String,
487    name: String,
488}
489
490impl Endpoint {
491    /// The endpoint `path` spells under `method`, or `None` when `path` is not spelled like
492    /// a path template at all.
493    ///
494    /// Refusing it here is refusing it where it is written, which is the same answer
495    /// [`Method::parse`] gives a method HTTP has no verb for: a caller learns at the call
496    /// site rather than finding a URL in a report that was supposed to hold none.
497    #[must_use]
498    pub fn parse(method: Method, path: &str) -> Option<Self> {
499        if !is_path_template(path) {
500            return None;
501        }
502        Some(Self {
503            method,
504            path: path.to_owned(),
505            name: format!("{} {path}", method.name()),
506        })
507    }
508    /// The method it is addressed with.
509    #[must_use]
510    pub const fn method(&self) -> Method {
511        self.method
512    }
513    /// The path template, without the method.
514    #[must_use]
515    pub fn path(&self) -> &str {
516        &self.path
517    }
518    /// What it is called in a report: the method and the template, as above.
519    #[must_use]
520    pub fn name(&self) -> &str {
521        &self.name
522    }
523}
524
525/// Whether `path` is spelled like one of GitHub's documented endpoint templates.
526///
527/// An absolute path of `/`-separated segments, each a literal of the bytes a documented
528/// endpoint uses or a single `{placeholder}`. A host, a query string, a fragment, an empty
529/// segment and anything over the length GitHub's own longest endpoint needs are all refused,
530/// because each of them is a way an addressed URL — which carries what a run touched —
531/// differs from the template it was built from.
532fn is_path_template(path: &str) -> bool {
533    if path.is_empty() || path.len() > 128 || !path.starts_with('/') {
534        return false;
535    }
536    path[1..].split('/').all(|segment| {
537        let placeholder = segment
538            .strip_prefix('{')
539            .and_then(|rest| rest.strip_suffix('}'));
540        match placeholder {
541            Some(name) => {
542                !name.is_empty()
543                    && name
544                        .bytes()
545                        .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
546            }
547            None => {
548                !segment.is_empty()
549                    && segment.bytes().all(|byte| {
550                        byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_' || byte == b'.'
551                    })
552            }
553        }
554    })
555}
556
557/// What one request asked for.
558#[derive(Debug, Clone, PartialEq, Eq)]
559pub enum Call {
560    /// A GraphQL request, which sends a document.
561    Document {
562        /// What the sender was doing, from
563        /// [`graphql::DOCUMENTS`] when the document is one of
564        /// this source's own, and from the caller's own name when it is not.
565        name: String,
566        /// The document's worst-case node count under the bindings that request sent, or
567        /// `None` when the calculation could not rule on the document — which is a defect
568        /// in the document rather than a cost of zero.
569        node_count: Option<u64>,
570    },
571    /// A REST request, which sends no document and has no node count.
572    Endpoint {
573        /// The endpoint it addressed. An [`Endpoint`] rather than a string, so a record
574        /// cannot hold the URL a run built — see that type for what it rules on.
575        endpoint: Endpoint,
576    },
577}
578
579impl Call {
580    /// What this call is called in a report.
581    #[must_use]
582    pub fn name(&self) -> &str {
583        match self {
584            Self::Document { name, .. } => name,
585            Self::Endpoint { endpoint } => endpoint.name(),
586        }
587    }
588    /// The budget it draws on.
589    #[must_use]
590    pub const fn budget(&self) -> Budget {
591        match self {
592            Self::Document { .. } => Budget::Graphql,
593            Self::Endpoint { .. } => Budget::Rest,
594        }
595    }
596    /// The most nodes it may return, for a GraphQL request that has such a number.
597    #[must_use]
598    pub const fn node_count(&self) -> Option<u64> {
599        match self {
600            Self::Document { node_count, .. } => *node_count,
601            Self::Endpoint { .. } => None,
602        }
603    }
604}
605
606/// One outgoing HTTP request, described before it is sent.
607///
608/// [`Sending::finished`], and [`Sending::answered`] for the ordinary case, are what turn it
609/// into a [`Request`]: a record with no outcome is one nobody could add up, so the outcome
610/// is the step that produces the record rather than a field that might be missing.
611///
612/// Everything a request carries before it is sent is settled by the constructor that made
613/// it, which is why there is no builder step here that could attach a reported GraphQL cost
614/// to a REST call — a budget metered in requests has no such figure, and GitHub reports
615/// none for one.
616#[derive(Debug, Clone, PartialEq, Eq)]
617pub struct Sending {
618    call: Call,
619    mode: Mode,
620    drawn: Drawn,
621}
622
623/// What a request draws on, and what only that kind of request can carry.
624///
625/// The reported cost lives inside the GraphQL arm rather than beside the call, because a
626/// REST request has nowhere to have got one from: GitHub meters that budget in requests and
627/// reports no per-call figure at all.
628#[derive(Debug, Clone, Copy, PartialEq, Eq)]
629enum Drawn {
630    /// The GraphQL budget, with GitHub's own reported cost for this call when the request
631    /// was shaped so GitHub reported one.
632    Graphql { reported_cost: Option<u64> },
633    /// The REST budget, which a call is its own measure of.
634    Rest,
635}
636
637impl Sending {
638    /// GitHub answered it, with the rate-limit facts its response's headers carried.
639    #[must_use]
640    pub fn answered(self, limits: RateLimit) -> Request {
641        self.finished(Outcome::Answered, limits)
642    }
643    /// However it ended, with the rate-limit facts its response's headers carried.
644    #[must_use]
645    pub fn finished(self, outcome: Outcome, limits: RateLimit) -> Request {
646        let spend = match (self.drawn, outcome) {
647            (_, Outcome::RateLimited) => Spend::not_run(),
648            (Drawn::Rest, _) => Spend::counted(),
649            (
650                Drawn::Graphql {
651                    reported_cost: Some(cost),
652                },
653                _,
654            ) => Spend::reported(cost),
655            (
656                Drawn::Graphql {
657                    reported_cost: None,
658                },
659                _,
660            ) => Spend::modelled(),
661        };
662        Request {
663            call: self.call,
664            mode: self.mode,
665            outcome,
666            limits,
667            spend,
668        }
669    }
670}
671
672/// What a GraphQL document this crate does not send is called until its sender names it.
673const UNNAMED_DOCUMENT: &str = "talking to GitHub";
674
675/// One outgoing HTTP request, and what it cost.
676#[derive(Debug, Clone, PartialEq, Eq)]
677pub struct Request {
678    call: Call,
679    mode: Mode,
680    outcome: Outcome,
681    limits: RateLimit,
682    spend: Spend,
683}
684
685impl Request {
686    /// Begin recording one GraphQL request carrying `document` under `variables`.
687    ///
688    /// The name comes from [`graphql::DOCUMENTS`] — the
689    /// inventory, not a second list — and the node count from
690    /// [`node_count`] under the page sizes `variables` really binds, so
691    /// a page read with a caller's smaller limit is counted at that limit rather than at
692    /// the worst case. Any page-size variable the request leaves unbound keeps the largest
693    /// value this source could send it, which is the worst case for exactly that document.
694    ///
695    /// `otherwise` names a document the inventory does not hold, which is how a caller's own
696    /// calls — a schema introspection, a residue sweep — are named beside this source's;
697    /// `None` leaves such a document under the placeholder, and a document the inventory
698    /// does hold keeps its entry's name either way.
699    ///
700    /// `reported_cost` is GitHub's own `cost` for **this** call, from a response to a
701    /// request shaped to report one. A `rateLimit(dryRun: true)` probe reports what some
702    /// other document would cost and never what this call spent, so its figure does not
703    /// belong here.
704    #[must_use]
705    pub fn graphql(
706        document: &str,
707        variables: &Value,
708        otherwise: Option<&str>,
709        reported_cost: Option<u64>,
710    ) -> Sending {
711        let name = document_name(document);
712        Sending {
713            call: Call::Document {
714                name: match (name, otherwise) {
715                    (UNNAMED_DOCUMENT, Some(otherwise)) => otherwise.to_owned(),
716                    (name, _) => name.to_owned(),
717                },
718                // llmlint: ignore[invalid_states_unrepresentable] `None` here is not a missing invariant, it is the honest reading of a document this calculation could not rule on — and the state is reachable, because this constructor is public and `otherwise` exists for a caller's OWN documents (a schema introspection, a residue sweep) which no test of this source's inventory sweeps. Refusing to build the record would lose the request from the accounting entirely, which is strictly worse than recording a call whose node count is unknown. It is never totalled as zero: `Session::total_node_count` filter-maps it out, the report prints the total "over N GraphQL requests that have one", and the by-document section lists only those. Every document this source itself sends is held countable by `tests/node_count.rs`.
719                node_count: node_count(document, &bindings(variables)).ok(),
720            },
721            mode: Mode::of_document(document),
722            drawn: Drawn::Graphql { reported_cost },
723        }
724    }
725    /// Begin recording one REST request against `endpoint`.
726    ///
727    /// `endpoint` is the endpoint rather than the URL that was built from it — `GET
728    /// /repos/{owner}/{repo}/labels`, not the repository and label a run happened to name —
729    /// because a report is compared between runs and carries no board content. It is an
730    /// [`Endpoint`] rather than a string for that reason: what a report may hold is settled
731    /// where the endpoint is written, not here.
732    #[must_use]
733    pub fn rest(endpoint: Endpoint) -> Sending {
734        Sending {
735            mode: endpoint.method().mode(),
736            call: Call::Endpoint { endpoint },
737            drawn: Drawn::Rest,
738        }
739    }
740    /// What was called.
741    #[must_use]
742    pub const fn call(&self) -> &Call {
743        &self.call
744    }
745    /// What it is called in a report.
746    #[must_use]
747    pub fn name(&self) -> &str {
748        self.call.name()
749    }
750    /// Whether it read or wrote.
751    #[must_use]
752    pub const fn mode(&self) -> Mode {
753        self.mode
754    }
755    /// How it ended.
756    #[must_use]
757    pub const fn outcome(&self) -> Outcome {
758        self.outcome
759    }
760    /// The budget it drew on.
761    #[must_use]
762    pub const fn budget(&self) -> Budget {
763        self.call.budget()
764    }
765    /// The most nodes it may return, for a GraphQL request that has such a number.
766    #[must_use]
767    pub const fn node_count(&self) -> Option<u64> {
768        self.call.node_count()
769    }
770    /// What it is attributed against its budget, and where that figure came from.
771    #[must_use]
772    pub const fn spend(&self) -> Spend {
773        self.spend
774    }
775    /// The rate-limit facts this response's own headers carried.
776    #[must_use]
777    pub const fn rate_limit(&self) -> &RateLimit {
778        &self.limits
779    }
780}
781
782/// What this source calls the document it just sent, from the inventory rather than a copy.
783fn document_name(document: &str) -> &str {
784    graphql::DOCUMENTS
785        .iter()
786        .find(|(known, _)| *known == document)
787        .map_or(UNNAMED_DOCUMENT, |(_, doing)| *doing)
788}
789
790/// The page sizes one request really bound, over the largest this source can send.
791///
792/// Starting from [`largest_page_sizes`] rather than from nothing is what keeps a document
793/// countable when the request leaves one of its page-size variables out: the calculation
794/// refuses a `first:` it has no binding for, and the worst case is the honest answer for a
795/// size nobody narrowed. A variable no `first:` references — a project number, a page
796/// cursor — is ignored by the calculation, so passing every integer through costs nothing.
797fn bindings(variables: &Value) -> Variables {
798    let mut bindings = largest_page_sizes();
799    if let Some(bound) = variables.as_object() {
800        for (name, value) in bound {
801            if let Some(size) = value.as_u64().and_then(|size| u32::try_from(size).ok()) {
802                bindings.insert(name.clone(), size);
803            }
804        }
805    }
806    bindings
807}
808
809/// Every request one session sent, and what each cost.
810///
811/// It is on this crate's ordinary code path — [`crate::GitHubProjectsSource`] records into
812/// one at the single place a request leaves the crate, with no environment variable, no
813/// feature and no build configuration to know about, because an instrument nobody switches
814/// on measures nothing. It is constructible and recordable-into from outside the crate for
815/// the other half of the same reason: a caller making its own calls beside this source's —
816/// the credentialed lane verifying a schema, sweeping residue, cleaning up — accounts for
817/// the whole session rather than for this source's share of it.
818#[derive(Debug, Default)]
819pub struct Accounting {
820    requests: Mutex<Vec<Request>>,
821    estimates: Mutex<BTreeMap<Budget, u64>>,
822}
823
824impl Accounting {
825    /// An accounting with nothing in it.
826    #[must_use]
827    pub fn new() -> Self {
828        Self::default()
829    }
830    /// Record what a caller estimated this session would spend against `budget`, before
831    /// it started.
832    ///
833    /// An estimate is not an observation and is kept apart from one everywhere below: it is
834    /// what a precondition decided on, and the point of carrying it here is that a report
835    /// can put it beside what the session really spent, so a reader sees how far the model
836    /// was from GitHub's own figures rather than being told to trust it. The last estimate
837    /// recorded for a budget is the one reported, because a precondition decides once.
838    pub fn estimate(&self, budget: Budget, cost: u64) {
839        self.estimates
840            .lock()
841            .unwrap_or_else(std::sync::PoisonError::into_inner)
842            .insert(budget, cost);
843    }
844    /// Record one request.
845    pub fn record(&self, request: Request) {
846        // A poisoned lock costs the accounting, never the work: a panic elsewhere must not
847        // turn measuring the session into a second failure on top of the first.
848        self.requests
849            .lock()
850            .unwrap_or_else(std::sync::PoisonError::into_inner)
851            .push(request);
852    }
853    /// A snapshot of what has been recorded so far: a value to hold and compare, never a
854    /// live borrow of this accounting.
855    #[must_use]
856    pub fn snapshot(&self) -> Session {
857        Session {
858            requests: self
859                .requests
860                .lock()
861                .unwrap_or_else(std::sync::PoisonError::into_inner)
862                .clone(),
863            estimates: self
864                .estimates
865                .lock()
866                .unwrap_or_else(std::sync::PoisonError::into_inner)
867                .clone(),
868        }
869    }
870}
871
872/// One session's requests, as a value a caller can hold, compare and report on.
873#[derive(Debug, Clone, Default, PartialEq, Eq)]
874pub struct Session {
875    requests: Vec<Request>,
876    estimates: BTreeMap<Budget, u64>,
877}
878
879impl Session {
880    /// The requests it holds, in the order they were recorded.
881    #[must_use]
882    pub fn requests(&self) -> &[Request] {
883        &self.requests
884    }
885    /// How many requests this session sent.
886    #[must_use]
887    pub fn total_requests(&self) -> usize {
888        self.requests.len()
889    }
890    /// The nodes every GraphQL request that has a node count may return, added up.
891    #[must_use]
892    pub fn total_node_count(&self) -> u64 {
893        self.requests
894            .iter()
895            .filter_map(Request::node_count)
896            .fold(0, u64::saturating_add)
897    }
898    /// What a precondition estimated this session would spend against `budget`, before it
899    /// started, when one estimated anything at all.
900    #[must_use]
901    pub fn estimated(&self, budget: Budget) -> Option<u64> {
902        self.estimates.get(&budget).copied()
903    }
904    /// What this session is **attributed** against `budget`, summed per call.
905    ///
906    /// Attributed rather than spent, and the difference is the point: only a call GitHub
907    /// itself reported a cost for is a measurement. A call against a budget metered in
908    /// requests is its own measure, and every other GraphQL call carries [`Basis::Modelled`]
909    /// — one point, GitHub's documented minimum, which is a **lower bound**. So this is what
910    /// the session can account for, never a claim about what GitHub charged; [`Self::budgets`]
911    /// is where the same total comes apart by basis, and that is what says how much of it is
912    /// measured.
913    #[must_use]
914    pub fn attributed(&self, budget: Budget) -> u64 {
915        self.requests
916            .iter()
917            .filter(|request| request.budget() == budget)
918            .fold(0, |total, request| {
919                total.saturating_add(request.spend.amount)
920            })
921    }
922    /// Every budget this session touched, in a stable order.
923    #[must_use]
924    pub fn budgets(&self) -> Vec<BudgetReport> {
925        let mut touched: BTreeMap<Budget, BudgetReport> = BTreeMap::new();
926        // Seeded from the estimates first: a budget a precondition sized this session
927        // against and the session then never reached is worth reporting as exactly that,
928        // rather than vanishing from the report that is supposed to compare the two.
929        for (budget, estimated) in &self.estimates {
930            touched
931                .entry(*budget)
932                .or_insert_with(|| BudgetReport::of(*budget))
933                .estimated = Some(*estimated);
934        }
935        for request in &self.requests {
936            let budget = request.budget();
937            touched
938                .entry(budget)
939                .or_insert_with(|| BudgetReport::of(budget))
940                .record(request);
941        }
942        touched.into_values().collect()
943    }
944    /// The session report: what a person puts two runs of side by side.
945    #[must_use]
946    pub fn report(&self) -> String {
947        let mut report = String::from("github-projects session accounting\n");
948        let count = |mode: Mode| {
949            self.requests
950                .iter()
951                .filter(|request| request.mode == mode)
952                .count()
953        };
954        let ended = |outcome: Outcome| {
955            self.requests
956                .iter()
957                .filter(|request| request.outcome == outcome)
958                .count()
959        };
960        let _ = writeln!(
961            report,
962            "requests {}: {} {}, {} {}; {} {}, {} {}, {} {}",
963            self.total_requests(),
964            count(Mode::Read),
965            Mode::Read.name(),
966            count(Mode::Write),
967            Mode::Write.name(),
968            ended(Outcome::Answered),
969            Outcome::Answered.name(),
970            ended(Outcome::Refused),
971            Outcome::Refused.name(),
972            ended(Outcome::RateLimited),
973            Outcome::RateLimited.name(),
974        );
975        report.push_str("requests by call\n");
976        let mut by_call: BTreeMap<&str, (usize, u64, usize)> = BTreeMap::new();
977        for request in &self.requests {
978            let entry = by_call.entry(request.name()).or_insert((0, 0, 0));
979            entry.0 += 1;
980            if let Some(nodes) = request.node_count() {
981                entry.1 = entry.1.saturating_add(nodes);
982                entry.2 += 1;
983            }
984        }
985        for (name, (requests, _, _)) in &by_call {
986            let _ = writeln!(report, "  {name:<52}{requests:>6}");
987        }
988        let counted = self
989            .requests
990            .iter()
991            .filter(|request| request.node_count().is_some())
992            .count();
993        let _ = writeln!(
994            report,
995            "node count {} over {counted} GraphQL requests that have one",
996            self.total_node_count(),
997        );
998        report.push_str("node count by document\n");
999        for (name, (_, nodes, requests)) in by_call.iter().filter(|(_, (_, _, with))| *with > 0) {
1000            let counted = format!(
1001                "{requests} {}",
1002                if *requests == 1 {
1003                    "request"
1004                } else {
1005                    "requests"
1006                }
1007            );
1008            let _ = writeln!(report, "  {name:<52}{counted:>14}{nodes:>12} nodes");
1009        }
1010        for budget in self.budgets() {
1011            report.push_str(&budget.render());
1012        }
1013        report
1014    }
1015}
1016
1017/// One budget a session drew on, with its own figures kept apart from the account's.
1018///
1019/// **Every figure here is a total over requests that really drew on this budget, so the only
1020/// way to have one is to add those requests up.** [`Session::budgets`] is that, and
1021/// [`BudgetReport::record`] is where a request joins one: the request count, the spend, its
1022/// three attributions and the account's own readings all move together, from the same record,
1023/// so a report cannot say it summarises nine requests while its attributions add up to four,
1024/// or carry a REST budget's figures under [`Budget::Graphql`]. The fields are read through
1025/// the accessors below for exactly the reason [`RateLimit`]'s are: a hand-assembled set of
1026/// totals would be a measurement of nothing, and this whole accounting exists because a
1027/// number nobody measured was argued about instead.
1028#[derive(Debug, Clone, PartialEq, Eq)]
1029pub struct BudgetReport {
1030    budget: Budget,
1031    estimated: Option<u64>,
1032    requests: usize,
1033    attributed: u64,
1034    reported: u64,
1035    modelled: u64,
1036    counted: u64,
1037    not_run: usize,
1038    limit: Option<u64>,
1039    used_by_the_account: Option<u64>,
1040    remaining_first_seen: Option<u64>,
1041    remaining_last_seen: Option<u64>,
1042}
1043
1044impl BudgetReport {
1045    /// A report of `budget` with nothing added to it yet.
1046    const fn of(budget: Budget) -> Self {
1047        Self {
1048            budget,
1049            estimated: None,
1050            requests: 0,
1051            attributed: 0,
1052            reported: 0,
1053            modelled: 0,
1054            counted: 0,
1055            not_run: 0,
1056            limit: None,
1057            used_by_the_account: None,
1058            remaining_first_seen: None,
1059            remaining_last_seen: None,
1060        }
1061    }
1062    /// Add one of this budget's requests, and everything its response said about it.
1063    ///
1064    /// The one place these totals move, which is what makes them agree with each other and
1065    /// with the requests they are over.
1066    fn record(&mut self, request: &Request) {
1067        self.requests += 1;
1068        self.attributed = self.attributed.saturating_add(request.spend.amount);
1069        match request.spend.basis {
1070            Basis::Reported => {
1071                self.reported = self.reported.saturating_add(request.spend.amount);
1072            }
1073            Basis::Modelled => {
1074                self.modelled = self.modelled.saturating_add(request.spend.amount);
1075            }
1076            Basis::Counted => {
1077                self.counted = self.counted.saturating_add(request.spend.amount);
1078            }
1079            // Attributed nothing, and counted as one of the requests that ran into the
1080            // limiter. Its headers are still read below — a refusal for a spent budget is
1081            // the response whose figures say most about that budget's state.
1082            Basis::NotRun => self.not_run += 1,
1083        }
1084        if let Some(limit) = request.limits.limit() {
1085            self.limit = Some(limit);
1086        }
1087        if let Some(used) = request.limits.used_by_the_account() {
1088            self.used_by_the_account = Some(used);
1089        }
1090        if let Some(remaining) = request.limits.remaining() {
1091            self.remaining_first_seen.get_or_insert(remaining);
1092            self.remaining_last_seen = Some(remaining);
1093        }
1094    }
1095    /// Which budget.
1096    #[must_use]
1097    pub const fn budget(&self) -> Budget {
1098        self.budget
1099    }
1100    /// What a precondition estimated this session would spend against it before it
1101    /// started, when one estimated anything at all.
1102    ///
1103    /// It is an estimate rather than a measurement, and the report says so where it prints
1104    /// it: what makes it worth carrying is that a reader can see how far the model was from
1105    /// what the session really spent.
1106    #[must_use]
1107    pub const fn estimated(&self) -> Option<u64> {
1108        self.estimated
1109    }
1110    /// How many of this session's requests drew on it.
1111    #[must_use]
1112    pub const fn requests(&self) -> usize {
1113        self.requests
1114    }
1115    /// What this session itself is **attributed** against it, summed per call.
1116    ///
1117    /// Attributed rather than spent, for the reason [`Session::attributed`] gives: some of
1118    /// this figure is [`Basis::Modelled`], GitHub's documented one-point minimum, which is a
1119    /// lower bound and not a measurement. The four figures below are the same total split by
1120    /// basis, which is what says how much of it GitHub itself reported.
1121    #[must_use]
1122    pub const fn attributed(&self) -> u64 {
1123        self.attributed
1124    }
1125    /// How much of that GitHub itself reported.
1126    #[must_use]
1127    pub const fn reported(&self) -> u64 {
1128        self.reported
1129    }
1130    /// How much of it is this repository's one-point-per-call lower bound.
1131    #[must_use]
1132    pub const fn modelled(&self) -> u64 {
1133        self.modelled
1134    }
1135    /// How much of it is a count of requests against a budget metered in requests.
1136    #[must_use]
1137    pub const fn counted(&self) -> u64 {
1138        self.counted
1139    }
1140    /// How many of its requests a rate limiter refused, so they never ran and are
1141    /// attributed nothing.
1142    #[must_use]
1143    pub const fn not_run(&self) -> usize {
1144        self.not_run
1145    }
1146    /// The whole allowance, as GitHub's own headers reported it.
1147    #[must_use]
1148    pub const fn limit(&self) -> Option<u64> {
1149        self.limit
1150    }
1151    /// What the **account** had spent, as GitHub's own headers reported it. Not this
1152    /// session's spend: other work draws on the same budget in the same window.
1153    #[must_use]
1154    pub const fn used_by_the_account(&self) -> Option<u64> {
1155        self.used_by_the_account
1156    }
1157    /// The allowance remaining when this session's first request against this budget was
1158    /// answered.
1159    #[must_use]
1160    pub const fn remaining_first_seen(&self) -> Option<u64> {
1161        self.remaining_first_seen
1162    }
1163    /// The allowance remaining when its last one was.
1164    #[must_use]
1165    pub const fn remaining_last_seen(&self) -> Option<u64> {
1166        self.remaining_last_seen
1167    }
1168    /// How far the **account's** remaining allowance fell while this session ran.
1169    ///
1170    /// A fall rather than a movement, and the difference is not pedantry: an allowance that
1171    /// *rose* is the hourly window having reset mid-session, which is not a negative spend
1172    /// and answers zero here. Either way it is not this session's spend, and the report says
1173    /// so where it prints it — this account is shared, so the difference between two
1174    /// readings of a shared counter measures the account.
1175    #[must_use]
1176    pub fn account_allowance_fall(&self) -> Option<u64> {
1177        Some(
1178            self.remaining_first_seen?
1179                .saturating_sub(self.remaining_last_seen?),
1180        )
1181    }
1182    /// This budget's lines of the session report.
1183    #[must_use]
1184    pub fn render(&self) -> String {
1185        let unknown = |value: Option<u64>| {
1186            value.map_or_else(|| "not reported".to_owned(), |value| value.to_string())
1187        };
1188        let mut lines = format!(
1189            "budget {}, metered in {}\n",
1190            self.budget.name(),
1191            self.budget.unit()
1192        );
1193        let _ = writeln!(
1194            lines,
1195            "  this session sent {} requests and is attributed {} {} — measured only where \
1196             GitHub reported it: {} {}, {} {}, {} {}; {} {}",
1197            self.requests,
1198            self.attributed,
1199            self.budget.unit(),
1200            self.reported,
1201            Basis::Reported.name(),
1202            self.modelled,
1203            Basis::Modelled.name(),
1204            self.counted,
1205            Basis::Counted.name(),
1206            self.not_run,
1207            Basis::NotRun.name(),
1208        );
1209        if let Some(estimated) = self.estimated {
1210            let _ = writeln!(
1211                lines,
1212                "  a precondition estimated {estimated} {} before this session started, \
1213                 against the {} attributed above",
1214                self.budget.unit(),
1215                self.attributed,
1216            );
1217        }
1218        let _ = writeln!(
1219            lines,
1220            "  the account: limit {}, {} used, {} remaining when this session finished",
1221            unknown(self.limit),
1222            unknown(self.used_by_the_account),
1223            unknown(self.remaining_last_seen),
1224        );
1225        let _ = writeln!(
1226            lines,
1227            "  the account's remaining allowance fell {} while this session ran; that is the \
1228             account's own consumption and not this session's spend, because other work \
1229             draws on the same budget in the same window",
1230            unknown(self.account_allowance_fall()),
1231        );
1232        lines
1233    }
1234}