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}