Skip to main content

github_graphql_node_count/
lib.rs

1//! Compute, offline, the two numbers GitHub's GraphQL API charges a query — the
2//! worst-case **node count** it may return and the **rate-limit points** one call
3//! of it spends — from the query text and its page-size variable bindings alone,
4//! before the query is ever sent.
5//!
6//! # Two numbers, two limits
7//!
8//! GitHub meters two different numbers against two different limits. Both are
9//! computed here, from one traversal of the document, and confusing them is the
10//! mistake this section exists to prevent.
11//!
12//! | | what it counts | what it is limited against | computed by |
13//! | --- | --- | --- | --- |
14//! | **`nodeCount`** | the maximum number of nodes **one query may return** | [`NODE_LIMIT`], **per query** | [`node_count`] |
15//! | **`cost`** | the rate-limit **points** one call spends | an hourly budget, **per credential**, across everything that credential does | [`point_cost`] |
16//!
17//! They are not two views of one quantity. A cheap query run in a loop exhausts
18//! the hourly budget without ever approaching [`NODE_LIMIT`]; a single enormous
19//! query is rejected outright while costing a handful of points. So a consumer
20//! gating on one is not gating on the other, and the two are never renamed into
21//! each other here.
22//!
23//! # The rules, and where they come from
24//!
25//! GitHub publishes both at
26//! <https://docs.github.com/en/graphql/overview/rate-limits-and-node-limits-for-the-graphql-api>.
27//!
28//! For the node count: it **multiplies** down a nested path, **sums** across
29//! sibling paths, every connection supplies a `first` or a `last` inside
30//! `1..=100`, and the limit one query may not reach is 500,000.
31//!
32//! For the points: add up the number of requests needed to fulfil each unique
33//! connection in the call, assuming every request reaches its page-size limit;
34//! divide that aggregate by 100 and round to the nearest whole number; and never
35//! answer below GitHub's stated minimum of 1. A connection is resolved once per
36//! parent node, so the requests it needs are the product of the page sizes
37//! **strictly above** it — which is the same quantity the node count multiplies
38//! by that connection's own page size, and why one walk answers both.
39//!
40//! Check this crate against that page rather than against our confidence.
41//!
42//! # No schema, by design
43//!
44//! [`node_count`] and [`point_cost`] are handed document text and nothing else.
45//! They reach no network, read no credential, and consult no GraphQL schema — so
46//! they cannot know which fields are connections by type. Instead:
47//!
48//! * a field carrying a `first:` or a `last:` argument **is** a connection: it
49//!   multiplies the count beneath it, and it is one of the connections whose
50//!   requests are aggregated;
51//! * every other field contributes no multiplier, no nodes and no requests of its
52//!   own.
53//!
54//! That is what makes this crate runnable in a fork pull request with no secret,
55//! which is the whole reason a consumer can put it in a gate. The price is one
56//! blind spot, stated plainly and applying to **both** answers: **a connection
57//! that supplies neither `first` nor `last` is invalid to GitHub and invisible
58//! here.** Such a field is treated as an ordinary field, so both the node count
59//! and the point cost are an undercount rather than an error. A field supplying
60//! *both* is also invalid to GitHub; the larger of the two is used, so both
61//! answers stay a worst case.
62//!
63//! # Example
64//!
65//! ```
66//! use github_graphql_node_count::{
67//!     node_count, point_cost, NodeCountError, Variables, NODE_LIMIT,
68//! };
69//!
70//! let document = r#"
71//!     query($repos: Int!) {
72//!       viewer {
73//!         repositories(first: $repos) {
74//!           edges { node { name issues(first: 10) { edges { node { title } } } } }
75//!         }
76//!       }
77//!     }
78//! "#;
79//! let variables = Variables::from([("repos".to_string(), 50)]);
80//!
81//! // 50 repositories + 50 x 10 issues.
82//! assert_eq!(node_count(document, &variables)?, 550);
83//! assert!(node_count(document, &variables)? < NODE_LIMIT);
84//!
85//! // The same document against the other limit: `repositories` is resolved
86//! // once and `issues` fifty times, so 51 requests round to one point.
87//! assert_eq!(point_cost(document, &variables)?, 1);
88//!
89//! // A page size GitHub would reject comes back as an error, not a number.
90//! let over = Variables::from([("repos".to_string(), 500)]);
91//! assert!(matches!(
92//!     node_count(document, &over),
93//!     Err(NodeCountError::PageSizeOutOfRange { .. })
94//! ));
95//! # Ok::<(), NodeCountError>(())
96//! ```
97
98#![deny(missing_docs)]
99#![deny(missing_debug_implementations)]
100
101mod count;
102mod error;
103
104pub use error::{NodeCountError, PageSizeArgument, Position};
105
106/// GitHub's published limit on the number of nodes one query may return.
107///
108/// > Individual calls cannot request more than 500,000 total nodes.
109///
110/// — [Rate limits and node limits for the GraphQL API](https://docs.github.com/en/graphql/overview/rate-limits-and-node-limits-for-the-graphql-api).
111///
112/// This is a **per-query** ceiling on `nodeCount`. It is not the hourly `cost`
113/// budget: that is a per-credential allowance spent across every call, and what
114/// one call of a document spends against it is [`point_cost`], not this.
115pub const NODE_LIMIT: u64 = 500_000;
116
117/// The integer bound each page-size variable a document names is given, keyed by
118/// variable name **without** the leading `$`.
119///
120/// A variable a document declares but no `first:`/`last:` argument references
121/// need not appear. A variable a `first:`/`last:` *does* reference must, or
122/// [`node_count`] and [`point_cost`] alike return
123/// [`NodeCountError::UnboundVariable`] — a declared default value is not
124/// consulted.
125///
126/// A `u32` rather than a validated page-size newtype: this alias is a contract
127/// with the repositories that call this crate, restated verbatim in their own
128/// builds, so it is not ours to narrow. The `1..=100` range GitHub requires is
129/// enforced where the value is *used*, and a binding outside it comes back as
130/// [`NodeCountError::PageSizeOutOfRange`] rather than being silently counted.
131// llmlint: ignore[invalid_states_unrepresentable] the type of this alias is a frozen
132// cross-repository contract (see the paragraph above); narrowing it to a newtype would break
133// the consumer written against it, so the range is validated at the one point of use instead.
134pub type Variables = std::collections::BTreeMap<String, u32>;
135
136/// The worst-case number of nodes the one operation in `document` may return,
137/// under `variables`, computed by GitHub's published rules.
138///
139/// The document must hold exactly one operation. Fragment definitions beside it
140/// are resolved, so the natural consumer shape — one shared fragment
141/// concatenated onto each of several operations, giving several
142/// single-operation documents — is counted correctly, once per document.
143///
144/// # Errors
145///
146/// Returns [`NodeCountError`] rather than panicking, returning zero, or
147/// returning a wrong count, when:
148///
149/// * the text does not parse as GraphQL ([`NodeCountError::Parse`]);
150/// * the document declares no operation ([`NodeCountError::NoOperation`]);
151/// * it declares more than one, which this signature cannot disambiguate
152///   ([`NodeCountError::MultipleOperations`]);
153/// * a `first:`/`last:` names a variable `variables` does not bind
154///   ([`NodeCountError::UnboundVariable`]);
155/// * a `first:`/`last:` value falls outside `1..=100`
156///   ([`NodeCountError::PageSizeOutOfRange`]) or is not an integer at all
157///   ([`NodeCountError::PageSizeNotAnInteger`]);
158/// * a spread names a fragment the document does not define
159///   ([`NodeCountError::UndefinedFragment`]), or the spreads form a cycle
160///   ([`NodeCountError::FragmentCycle`]);
161/// * the count grows past `u64` ([`NodeCountError::Overflow`]), which only a
162///   document far above [`NODE_LIMIT`] can do.
163pub fn node_count(document: &str, variables: &Variables) -> Result<u64, NodeCountError> {
164    count::totals(document, variables).map(|totals| totals.nodes)
165}
166
167/// The aggregate GitHub's step 1 produces for one call of the one operation in
168/// `document`, under `variables`: the number of requests needed to fulfil every
169/// unique connection in the call, assuming each reaches its page-size limit.
170///
171/// This is [`point_cost`]'s input rather than its answer — the raw number before
172/// the division by 100 and the rounding — exposed so a consumer can see what a
173/// document is charged *for*, and so a change that moves a document's price by
174/// less than a whole point is still visible. A connection is resolved once per
175/// parent node, so it adds the product of the page sizes **strictly above** it,
176/// and its own page size does not appear in this number at all.
177///
178/// # Errors
179///
180/// Exactly the failures [`node_count`] returns, meaning exactly the same things:
181/// the two answers come from one walk of one parse, so a document either yields
182/// both or fails identically for either.
183pub fn point_aggregate(document: &str, variables: &Variables) -> Result<u64, NodeCountError> {
184    count::totals(document, variables).map(|totals| totals.aggregate)
185}
186
187/// The rate-limit **points** one call of the one operation in `document` spends,
188/// under `variables`, computed by GitHub's published rules.
189///
190/// This is `cost`, metered **per hour** against a budget one credential shares
191/// across everything it does — not `nodeCount`, which is [`node_count`] and is
192/// limited per query at [`NODE_LIMIT`]. A consumer gating on one is not gating on
193/// the other.
194///
195/// The answer is `max(1, round(A / 100))`, where `A` is
196/// [`point_aggregate`]: GitHub divides the aggregate by 100, rounds to the
197/// nearest whole number, and publishes a minimum of one point per call. So a
198/// document with no connection at all, or one whose only connection is resolved
199/// once, costs 1 — that minimum, rather than a floor bolted on here.
200///
201/// Working without a schema, this shares [`node_count`]'s blind spot: a
202/// connection supplying neither `first` nor `last` is invalid to GitHub and
203/// invisible here, so the answer is an undercount rather than an error; one
204/// supplying both is charged at the larger, so the answer stays a worst case.
205///
206/// # Errors
207///
208/// Exactly the failures [`node_count`] returns, meaning exactly the same things —
209/// see its documentation for the list. There is no second error type, because
210/// there is no second parse and no second walk.
211///
212/// # Example
213///
214/// ```
215/// use github_graphql_node_count::{point_aggregate, point_cost, NodeCountError, Variables};
216///
217/// // `repositories` is resolved once, `issues` once per repository.
218/// let document = r#"
219///     query($repos: Int!) {
220///       viewer {
221///         repositories(first: $repos) {
222///           edges { node { issues(first: 100) { edges { node { title } } } } }
223///         }
224///       }
225///     }
226/// "#;
227///
228/// // 1 + 100 = 101 requests, which rounds to one point.
229/// let modest = Variables::from([("repos".to_string(), 100)]);
230/// assert_eq!(point_aggregate(document, &modest)?, 101);
231/// assert_eq!(point_cost(document, &modest)?, 1);
232///
233/// // A document with no connection is still charged GitHub's minimum.
234/// assert_eq!(point_cost("{ viewer { login } }", &modest)?, 1);
235/// # Ok::<(), NodeCountError>(())
236/// ```
237pub fn point_cost(document: &str, variables: &Variables) -> Result<u64, NodeCountError> {
238    point_aggregate(document, variables).map(count::points)
239}