@procedures/gh 0.55.0 → 0.55.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # @procedures/gh
2
2
 
3
+ ## 0.55.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 3a8881e: `@procedures/gh` has a client of the REST API of GitHub for programs that send many requests: `createRestClient`. It sends each request with `httpRequest` of `@procedures/http`, so a request starts no process.
8
+
9
+ - A GET with a stored answer is a conditional request with `if-none-match`. For an answer 304, the client gives the stored answer with the status 200.
10
+ - A sliding window of `@mark1russell7/service/limiter` holds the secondary limit of 900 points in each minute. A request that changes data waits one second after the request before it.
11
+ - After a refusal for the rate, the client sends no request until the time that GitHub gives in `retry-after` or `x-ratelimit-reset`.
12
+ - No error text of the client has the token or the body of an answer.
13
+
14
+ The package has two new dependencies: `@procedures/http` and `@mark1russell7/service`.
15
+ - @procedures/client@0.55.1
16
+ - @procedures/shell@0.55.1
17
+ - @procedures/http@0.55.1
18
+
3
19
  ## 0.55.0
4
20
 
5
21
  ### Patch Changes
package/README.md CHANGED
@@ -55,6 +55,31 @@ The GitHub CLI (`gh`) as procedures: pull requests, workflow runs, artifacts, re
55
55
  - With `paginate: true`, the pages of an array become one array. The pages of an object become an array of the pages.
56
56
  - The method is an input, and `PUT` or `DELETE` can replace or remove data. Thus `gh.api` declares `writes: ["network"]` and keeps the default: destructive.
57
57
 
58
+ ### The REST client
59
+
60
+ `createRestClient` is a client of the REST API for a program that sends many requests, for example a dispatcher of runners. It does not use `gh`, so a request starts no process.
61
+
62
+ ```typescript
63
+ import { createRestClient } from "@procedures/gh";
64
+
65
+ const github = createRestClient({ token: () => readToken() });
66
+ const answer = await github.request({ method: "GET", path: "/repos/owner/name/actions/runs", query: { status: "queued" } }, signal);
67
+ ```
68
+
69
+ - The client sends each request with `httpRequest` of `@procedures/http`, with its host policy and its limits of size and time.
70
+ - The token is a text, or a function that gives it before each request. A token of a GitHub App changes each hour.
71
+ - Each status is an answer: `{ status, body, cached }`. The client throws only when it gets no answer, or when a wait is too long.
72
+ - A GET with a stored answer is a conditional request. For an answer 304, the client gives the stored answer with the status 200 and `cached: true`.
73
+ - GitHub does not count an answer 304 for the primary limit. The client keeps 1,000 answers. The option `cacheEntries` changes the count.
74
+ - A sliding window of `@mark1russell7/service/limiter` holds the secondary limit: 900 points in each minute.
75
+ - A GET costs 1 point. A POST, a PUT, a PATCH or a DELETE costs 5 points, and it waits one second after the write before it.
76
+ - After a refusal for the rate (429, or 403 with a sign of the rate), the client waits the time of `retry-after` or until `x-ratelimit-reset`.
77
+ - With no time in the headers, the wait is one minute. Each refusal in a sequence doubles it, to 16 minutes.
78
+ - A request whose wait is longer than `maxWaitMs` (60 seconds) throws a `RestRateError` at once, and sends nothing.
79
+ - The client refuses a path with a query, a segment `.` or `..`, a backslash or a space. Thus a request stays on its endpoint.
80
+ - No error text of the client has the token or the body of an answer.
81
+ - `state()` gives the counts of the client: the requests, the answers 304, the refusals for the rate and the wait.
82
+
58
83
  ## Use
59
84
 
60
85
  ```typescript
@@ -81,3 +106,5 @@ Under the root of a host (`mark --fs-root`, `procedure-server --fs-root`, `MCP_F
81
106
  ## Tests
82
107
 
83
108
  `pnpm --filter @procedures/gh test` runs the procedures with a fake shell. The tests examine each argument list, the JSON of `gh`, the failures and the values that cannot become options.
109
+
110
+ The tests of the REST client use a fake transport and a fake clock. They examine the headers, the stored answers, the window of points, the waits after a refusal and the checks of a path.
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from "./module.js";
2
2
  export * from "./types.js";
3
+ export * from "./rest.js";
3
4
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,cAAc,aAAa,CAAC;AAC5B,cAAc,YAAY,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,cAAc,aAAa,CAAC;AAC5B,cAAc,YAAY,CAAC;AAE3B,cAAc,WAAW,CAAC"}
package/dist/index.js CHANGED
@@ -1,4 +1,6 @@
1
1
  // The root registers nothing: import "<package>/register", or give the module to createRegistry()
2
2
  export * from "./module.js";
3
3
  export * from "./types.js";
4
+ // The REST client: conditional requests and the limits of the rate, with no process for each request
5
+ export * from "./rest.js";
4
6
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,kGAAkG;AAClG,cAAc,aAAa,CAAC;AAC5B,cAAc,YAAY,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,kGAAkG;AAClG,cAAc,aAAa,CAAC;AAC5B,cAAc,YAAY,CAAC;AAC3B,qGAAqG;AACrG,cAAc,WAAW,CAAC"}
package/dist/rest.d.ts ADDED
@@ -0,0 +1,141 @@
1
+ /**
2
+ * A client of the REST API of GitHub for a program that sends many requests: a dispatcher, a
3
+ * crawler or a loop that polls. It sends each request with `@procedures/http`, not with the `gh`
4
+ * CLI, so a request starts no process.
5
+ *
6
+ * - A GET with a stored answer is a conditional request. GitHub does not count an answer 304 for
7
+ * the primary limit, and the client then gives the stored answer.
8
+ * - A sliding window of `@mark1russell7/service/limiter` holds the secondary limit: 900 points in
9
+ * each minute. A GET costs 1 point, and a POST, a PUT, a PATCH or a DELETE costs 5 points.
10
+ * - A request that changes data waits one second after the request before it.
11
+ * - After a refusal for the rate, the client sends no request until the time that GitHub gives.
12
+ */
13
+ import type { Clock } from "@mark1russell7/service/clock";
14
+ /** The methods of a REST request. */
15
+ export type RestMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
16
+ /** One request to the REST API. */
17
+ export interface RestRequest {
18
+ readonly method: RestMethod;
19
+ /** This path is the endpoint after the base URL, for example `/repos/octo/app/actions/runs`. It has no query. */
20
+ readonly path: string;
21
+ /** These fields are the query of the URL. The client sorts them, so one query has one URL. */
22
+ readonly query?: Readonly<Record<string, string>> | undefined;
23
+ /** This value is the JSON body. `null` and `undefined` are no body, and a GET has no body. */
24
+ readonly body?: unknown;
25
+ }
26
+ /** The answer to one request. */
27
+ export interface RestAnswer {
28
+ /** This number is the status of the answer. A 304 with a stored answer gives 200. */
29
+ readonly status: number;
30
+ /** This value is the JSON body, or `null` for an empty body. */
31
+ readonly body: unknown;
32
+ /** This field is `true` when the body is the stored answer of an answer 304. */
33
+ readonly cached: boolean;
34
+ }
35
+ /** One exchange that a transport sends. */
36
+ export interface RestExchange {
37
+ readonly method: RestMethod;
38
+ readonly url: string;
39
+ readonly headers: Readonly<Record<string, string>>;
40
+ /** This value is the JSON body, or `undefined` for no body. */
41
+ readonly body: unknown;
42
+ }
43
+ /** The reply of a transport: the status, the headers and the JSON body. */
44
+ export interface RestReply {
45
+ readonly status: number;
46
+ readonly headers: Readonly<Record<string, string | readonly string[]>>;
47
+ readonly body: unknown;
48
+ }
49
+ /** A transport sends one exchange. It throws only when it gets no reply: each status is a reply. */
50
+ export type RestTransport = (exchange: RestExchange, signal: AbortSignal) => Promise<RestReply>;
51
+ /** The options of a REST client. */
52
+ export interface RestClientOptions {
53
+ /** This value is the token, or a function that gives it before each request. A token of a GitHub App changes each hour. */
54
+ readonly token: string | (() => string | Promise<string>);
55
+ /** This URL is the base of the API. The default is `https://api.github.com`. */
56
+ readonly baseUrl?: string | undefined;
57
+ /** This text is the version of the API. The default is `2022-11-28`. */
58
+ readonly apiVersion?: string | undefined;
59
+ /** This text is the `user-agent` header. The default is `procedures-gh`. */
60
+ readonly userAgent?: string | undefined;
61
+ /** This count is the points of the secondary limit in each minute. The default is 900. */
62
+ readonly pointsPerMinute?: number | undefined;
63
+ /** This count is the most stored answers. The default is 1,000, and 0 stores no answer. */
64
+ readonly cacheEntries?: number | undefined;
65
+ /** This time is the longest wait of a request for the limits, in milliseconds. The default is 60,000. */
66
+ readonly maxWaitMs?: number | undefined;
67
+ /** This transport sends each exchange. The default sends it with `httpRequest` of `@procedures/http`. */
68
+ readonly transport?: RestTransport | undefined;
69
+ /** This clock gives the monotonic time and the timers. The default is the clock of the platform. */
70
+ readonly clock?: Clock | undefined;
71
+ }
72
+ /** The counts of a REST client, for a log or a measure. */
73
+ export interface RestClientState {
74
+ /** This count is the exchanges that the transport sent. */
75
+ readonly sent: number;
76
+ /** This count is the answers 304 that gave a stored answer. */
77
+ readonly notModified: number;
78
+ /** This count is the refusals for the rate. */
79
+ readonly rateRefusals: number;
80
+ /** This count is the stored answers. */
81
+ readonly cacheEntries: number;
82
+ /** This number is the last `x-ratelimit-remaining` of GitHub, or `null` before it. */
83
+ readonly remaining: number | null;
84
+ /** This time is the wait until the client sends again after a refusal for the rate, in milliseconds. */
85
+ readonly blockedForMs: number;
86
+ }
87
+ /** A client of the REST API of GitHub. */
88
+ export interface RestClient {
89
+ /** This method sends one request. It throws when it gets no answer or when the wait for a limit is too long. */
90
+ request(request: RestRequest, signal: AbortSignal): Promise<RestAnswer>;
91
+ /** This method gives the counts of the client. */
92
+ state(): RestClientState;
93
+ }
94
+ /** This error tells that a request did not start because the wait for a limit is longer than `maxWaitMs`. */
95
+ export declare class RestRateError extends Error {
96
+ readonly name = "RestRateError";
97
+ /** This time is the wait until the request can start, in milliseconds. */
98
+ readonly retryAfterMs: number;
99
+ constructor(message: string, retryAfterMs: number);
100
+ }
101
+ /** The base URL of the REST API of GitHub. */
102
+ export declare const GITHUB_API_URL: string;
103
+ /** The version of the REST API that the client asks for. */
104
+ export declare const GITHUB_API_VERSION: string;
105
+ /** The points of the secondary limit of GitHub in each minute. */
106
+ export declare const GITHUB_POINTS_PER_MINUTE: number;
107
+ /** The points of a GET, and of a request that changes data. */
108
+ export declare const GITHUB_POINTS: {
109
+ readonly read: number;
110
+ readonly write: number;
111
+ };
112
+ /** The wait after a refusal for the rate with no time in its headers: one minute, then two times longer, to 16 minutes. */
113
+ export declare const RATE_WAIT_MS: {
114
+ readonly first: number;
115
+ readonly last: number;
116
+ };
117
+ /** The longest wait that the headers of an answer can set: one hour and one minute. */
118
+ export declare const LONGEST_RATE_WAIT_MS: number;
119
+ /**
120
+ * This function decides the wait after a refusal for the rate, in milliseconds, by the rules of
121
+ * GitHub. A `retry-after` header gives the wait. With `x-ratelimit-remaining` 0, the wait ends at
122
+ * `x-ratelimit-reset`, on the time of the `date` header. Else the wait is one minute, two times
123
+ * longer for each refusal in a sequence, to 16 minutes.
124
+ */
125
+ export declare function rateWaitMs(reply: RestReply, strikes: number): number;
126
+ /**
127
+ * This function tells if a reply is a refusal for the rate. GitHub refuses with 429, or with 403
128
+ * and one of these: `x-ratelimit-remaining` 0, a `retry-after` header, or a message about a rate limit.
129
+ */
130
+ export declare function isRateRefusal(reply: RestReply): boolean;
131
+ /**
132
+ * The default transport: `httpRequest` of `@procedures/http`, with its host policy and its limits of size and time.
133
+ * A redirect to another origin does not send the token. The transport parses the JSON itself, so an error text has no part of the body.
134
+ */
135
+ export declare const httpTransport: RestTransport;
136
+ /**
137
+ * This function makes a client of the REST API of GitHub. It refuses options that are not valid with
138
+ * a `RangeError` or a `TypeError`. No error text of the client has the token or the body of an answer.
139
+ */
140
+ export declare function createRestClient(options: RestClientOptions): RestClient;
141
+ //# sourceMappingURL=rest.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rest.d.ts","sourceRoot":"","sources":["../src/rest.ts"],"names":[],"mappings":"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"}
package/dist/rest.js ADDED
@@ -0,0 +1,296 @@
1
+ /**
2
+ * A client of the REST API of GitHub for a program that sends many requests: a dispatcher, a
3
+ * crawler or a loop that polls. It sends each request with `@procedures/http`, not with the `gh`
4
+ * CLI, so a request starts no process.
5
+ *
6
+ * - A GET with a stored answer is a conditional request. GitHub does not count an answer 304 for
7
+ * the primary limit, and the client then gives the stored answer.
8
+ * - A sliding window of `@mark1russell7/service/limiter` holds the secondary limit: 900 points in
9
+ * each minute. A GET costs 1 point, and a POST, a PUT, a PATCH or a DELETE costs 5 points.
10
+ * - A request that changes data waits one second after the request before it.
11
+ * - After a refusal for the rate, the client sends no request until the time that GitHub gives.
12
+ */
13
+ import { realClock } from "@mark1russell7/service/clock";
14
+ import { keyedLimiter, memoryStore, retryAfterSeconds } from "@mark1russell7/service/limiter";
15
+ import { httpRequest } from "@procedures/http";
16
+ /** This error tells that a request did not start because the wait for a limit is longer than `maxWaitMs`. */
17
+ export class RestRateError extends Error {
18
+ name = "RestRateError";
19
+ /** This time is the wait until the request can start, in milliseconds. */
20
+ retryAfterMs;
21
+ constructor(message, retryAfterMs) {
22
+ super(message);
23
+ this.retryAfterMs = retryAfterMs;
24
+ }
25
+ }
26
+ /** The base URL of the REST API of GitHub. */
27
+ export const GITHUB_API_URL = "https://api.github.com";
28
+ /** The version of the REST API that the client asks for. */
29
+ export const GITHUB_API_VERSION = "2022-11-28";
30
+ /** The points of the secondary limit of GitHub in each minute. */
31
+ export const GITHUB_POINTS_PER_MINUTE = 900;
32
+ /** The points of a GET, and of a request that changes data. */
33
+ export const GITHUB_POINTS = { read: 1, write: 5 };
34
+ /** The wait after a refusal for the rate with no time in its headers: one minute, then two times longer, to 16 minutes. */
35
+ export const RATE_WAIT_MS = { first: 60_000, last: 960_000 };
36
+ /** The longest wait that the headers of an answer can set: one hour and one minute. */
37
+ export const LONGEST_RATE_WAIT_MS = 3_660_000;
38
+ const METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
39
+ const isCount = (value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
40
+ /** This function gives one header of a reply with its name in lowercase, or `undefined`. */
41
+ function header(reply, name) {
42
+ for (const [key, value] of Object.entries(reply.headers)) {
43
+ if (key.toLowerCase() !== name)
44
+ continue;
45
+ return typeof value === "string" ? value : value.join(", ");
46
+ }
47
+ return undefined;
48
+ }
49
+ /** This function reads a whole number of 0 or more from a header, or gives `null`. */
50
+ function wholeNumber(text) {
51
+ if (text === undefined || !/^\s*\d{1,15}\s*$/.test(text))
52
+ return null;
53
+ return Number(text.trim());
54
+ }
55
+ /** This function examines the path of a request. A path that can leave its endpoint is refused. */
56
+ function checkedPath(path) {
57
+ if (typeof path !== "string" || !path.startsWith("/") || path.includes("//") || /[?#\\\s\u0000-\u001f\u007f]/.test(path)) {
58
+ throw new TypeError("The path of a REST request is not a path that starts with / and has no query.");
59
+ }
60
+ const segments = path.split("/").slice(1);
61
+ if (segments.some((segment) => /^(\.|%2e){1,2}$/i.test(segment))) {
62
+ throw new TypeError("The path of a REST request has a segment . or ..");
63
+ }
64
+ return path;
65
+ }
66
+ /**
67
+ * This function decides the wait after a refusal for the rate, in milliseconds, by the rules of
68
+ * GitHub. A `retry-after` header gives the wait. With `x-ratelimit-remaining` 0, the wait ends at
69
+ * `x-ratelimit-reset`, on the time of the `date` header. Else the wait is one minute, two times
70
+ * longer for each refusal in a sequence, to 16 minutes.
71
+ */
72
+ export function rateWaitMs(reply, strikes) {
73
+ const retryAfter = wholeNumber(header(reply, "retry-after"));
74
+ if (retryAfter !== null)
75
+ return Math.min(retryAfter * 1000, LONGEST_RATE_WAIT_MS);
76
+ const remaining = wholeNumber(header(reply, "x-ratelimit-remaining"));
77
+ const reset = wholeNumber(header(reply, "x-ratelimit-reset"));
78
+ const date = Date.parse(header(reply, "date") ?? "");
79
+ if (remaining === 0 && reset !== null && Number.isFinite(date)) {
80
+ return Math.min(Math.max(reset * 1000 - date, 0) + 1000, LONGEST_RATE_WAIT_MS);
81
+ }
82
+ return Math.min(RATE_WAIT_MS.first * 2 ** Math.max(strikes, 0), RATE_WAIT_MS.last);
83
+ }
84
+ /**
85
+ * This function tells if a reply is a refusal for the rate. GitHub refuses with 429, or with 403
86
+ * and one of these: `x-ratelimit-remaining` 0, a `retry-after` header, or a message about a rate limit.
87
+ */
88
+ export function isRateRefusal(reply) {
89
+ if (reply.status === 429)
90
+ return true;
91
+ if (reply.status !== 403)
92
+ return false;
93
+ if (wholeNumber(header(reply, "x-ratelimit-remaining")) === 0)
94
+ return true;
95
+ if (header(reply, "retry-after") !== undefined)
96
+ return true;
97
+ const message = typeof reply.body === "object" && reply.body !== null ? reply.body.message : undefined;
98
+ return typeof message === "string" && /rate limit/i.test(message);
99
+ }
100
+ /**
101
+ * The default transport: `httpRequest` of `@procedures/http`, with its host policy and its limits of size and time.
102
+ * A redirect to another origin does not send the token. The transport parses the JSON itself, so an error text has no part of the body.
103
+ */
104
+ export const httpTransport = async (exchange, signal) => {
105
+ const reply = await httpRequest({ method: exchange.method, url: exchange.url, headers: { ...exchange.headers }, json: exchange.body, responseType: "text" }, { signal });
106
+ const text = typeof reply.body === "string" ? reply.body : "";
107
+ let body = null;
108
+ if (text.trim().length > 0) {
109
+ try {
110
+ body = JSON.parse(text);
111
+ }
112
+ catch {
113
+ throw new Error(`The answer ${reply.status} to ${exchange.method} ${new URL(exchange.url).pathname.slice(0, 200)} is not JSON.`);
114
+ }
115
+ }
116
+ return { status: reply.status, headers: reply.headers, body };
117
+ };
118
+ /** This function waits on the clock. The signal ends the wait with an error. */
119
+ function pause(clock, ms, signal, what) {
120
+ // A wait on a clock with a signal has no function in @mark1russell7/algebra/clock yet. This copy goes when it has one.
121
+ return new Promise((resolve, reject) => {
122
+ if (signal.aborted) {
123
+ reject(new Error(`The signal stopped the request ${what}.`));
124
+ return;
125
+ }
126
+ const stop = () => {
127
+ cancel();
128
+ reject(new Error(`The signal stopped the request ${what}.`));
129
+ };
130
+ const cancel = clock.after(ms, () => {
131
+ signal.removeEventListener("abort", stop);
132
+ resolve();
133
+ });
134
+ signal.addEventListener("abort", stop, { once: true });
135
+ });
136
+ }
137
+ /**
138
+ * This function makes a client of the REST API of GitHub. It refuses options that are not valid with
139
+ * a `RangeError` or a `TypeError`. No error text of the client has the token or the body of an answer.
140
+ */
141
+ export function createRestClient(options) {
142
+ const base = new URL(options.baseUrl ?? GITHUB_API_URL);
143
+ if (base.protocol !== "https:" || base.search !== "" || base.hash !== "" || base.username !== "" || base.password !== "") {
144
+ throw new TypeError("The base URL of a REST client is not an https URL with no query and no credential.");
145
+ }
146
+ const basePath = base.pathname.replace(/\/+$/, "");
147
+ const apiVersion = options.apiVersion ?? GITHUB_API_VERSION;
148
+ const userAgent = options.userAgent ?? "procedures-gh";
149
+ for (const [name, value] of [["version", apiVersion], ["user agent", userAgent]]) {
150
+ if (typeof value !== "string" || value.length === 0 || /[\r\n]/.test(value)) {
151
+ throw new TypeError(`The ${name} of a REST client is not a text with a character and no line break.`);
152
+ }
153
+ }
154
+ const pointsPerMinute = options.pointsPerMinute ?? GITHUB_POINTS_PER_MINUTE;
155
+ if (!Number.isSafeInteger(pointsPerMinute) || pointsPerMinute < GITHUB_POINTS.write) {
156
+ throw new RangeError(`The points of a REST client in each minute are not a whole number of ${GITHUB_POINTS.write} or more.`);
157
+ }
158
+ const cacheEntries = options.cacheEntries ?? 1000;
159
+ if (!isCount(cacheEntries))
160
+ throw new RangeError("The stored answers of a REST client are not a whole number of 0 or more.");
161
+ const maxWaitMs = options.maxWaitMs ?? 60_000;
162
+ if (!isCount(maxWaitMs))
163
+ throw new RangeError("The longest wait of a REST client is not a whole number of milliseconds of 0 or more.");
164
+ if (typeof options.token !== "string" && typeof options.token !== "function") {
165
+ throw new TypeError("The token of a REST client is not a text or a function.");
166
+ }
167
+ const transport = options.transport ?? httpTransport;
168
+ const clock = options.clock ?? realClock;
169
+ const store = memoryStore(clock);
170
+ const points = keyedLimiter("github-points", { key: "token", policy: { type: "sliding-window", limit: { value: pointsPerMinute, unit: "count" }, window: { value: 60, unit: "s" } }, store: { kind: "local" }, evict: { maxKeys: { value: 1, unit: "count" } } }, store);
171
+ const writes = keyedLimiter("github-writes", { key: "token", policy: { type: "sliding-window", limit: { value: 1, unit: "count" }, window: { value: 1, unit: "s" } }, store: { kind: "local" }, evict: { maxKeys: { value: 1, unit: "count" } } }, store);
172
+ const cache = new Map();
173
+ let blockedUntil = Number.NEGATIVE_INFINITY;
174
+ let strikes = 0;
175
+ let remaining = null;
176
+ let sent = 0;
177
+ let notModified = 0;
178
+ let rateRefusals = 0;
179
+ const token = async () => {
180
+ const value = typeof options.token === "string" ? options.token : await options.token();
181
+ if (typeof value !== "string" || value.length === 0 || /[\s]/.test(value)) {
182
+ throw new TypeError("The token of a REST client is not a text with a character and no space or line break.");
183
+ }
184
+ return value;
185
+ };
186
+ /** This function waits until the limiter permits a take, or throws when the wait passes the deadline. */
187
+ const admit = async (limiter, units, deadline, signal, what) => {
188
+ for (;;) {
189
+ const decision = limiter.tryTakeSync("token", units);
190
+ if (decision.allowed)
191
+ return;
192
+ if (decision.cause === "capacity")
193
+ throw new RangeError(`The request ${what} costs more points than a minute has.`);
194
+ const wait = Math.max(1, retryAfterSeconds(decision.retryAfter)) * 1000;
195
+ if (clock.now() + wait > deadline)
196
+ throw new RestRateError(`The request ${what} waits ${wait} ms for the limit of the rate, more than its longest wait.`, wait);
197
+ await pause(clock, wait, signal, what);
198
+ }
199
+ };
200
+ /** This function waits until the block of a refusal for the rate ends, or throws when the block passes the deadline. */
201
+ const unblocked = async (deadline, signal, what) => {
202
+ for (;;) {
203
+ const blocked = Math.ceil(blockedUntil - clock.now());
204
+ if (blocked <= 0)
205
+ return;
206
+ if (clock.now() + blocked > deadline) {
207
+ throw new RestRateError(`GitHub refused a request for the rate. The request ${what} waits ${blocked} ms, more than its longest wait.`, blocked);
208
+ }
209
+ await pause(clock, blocked, signal, what);
210
+ }
211
+ };
212
+ const urlOf = (request) => {
213
+ const url = new URL(base.origin);
214
+ url.pathname = basePath + checkedPath(request.path);
215
+ const query = request.query ?? {};
216
+ if (typeof query !== "object" || query === null)
217
+ throw new TypeError("The query of a REST request is not a record of texts.");
218
+ for (const key of Object.keys(query).sort()) {
219
+ const value = query[key];
220
+ if (typeof value !== "string")
221
+ throw new TypeError(`The query field ${JSON.stringify(key)} of a REST request is not a text.`);
222
+ url.searchParams.append(key, value);
223
+ }
224
+ return url.href;
225
+ };
226
+ return {
227
+ async request(request, signal) {
228
+ if (typeof request !== "object" || request === null || !METHODS.includes(request.method)) {
229
+ throw new TypeError("The method of a REST request is not GET, POST, PUT, PATCH or DELETE.");
230
+ }
231
+ const what = `${request.method} ${typeof request.path === "string" ? request.path.slice(0, 200) : "?"}`;
232
+ const url = urlOf(request);
233
+ const read = request.method === "GET";
234
+ const body = request.body === null ? undefined : request.body;
235
+ if (read && body !== undefined)
236
+ throw new TypeError(`The request ${what} is a GET with a body.`);
237
+ if (signal.aborted)
238
+ throw new Error(`The signal stopped the request ${what}.`);
239
+ const deadline = clock.now() + maxWaitMs;
240
+ await unblocked(deadline, signal, what);
241
+ if (!read)
242
+ await admit(writes, 1, deadline, signal, what);
243
+ await admit(points, read ? GITHUB_POINTS.read : GITHUB_POINTS.write, deadline, signal, what);
244
+ // A refusal for the rate can come during the waits for the limiters, so the client examines the block again.
245
+ await unblocked(deadline, signal, what);
246
+ const headers = {
247
+ accept: "application/vnd.github+json",
248
+ authorization: `Bearer ${await token()}`,
249
+ "user-agent": userAgent,
250
+ "x-github-api-version": apiVersion,
251
+ };
252
+ const stored = read && cacheEntries > 0 ? cache.get(url) : undefined;
253
+ if (stored !== undefined)
254
+ headers["if-none-match"] = stored.etag;
255
+ sent += 1;
256
+ const reply = await transport({ method: request.method, url, headers, body }, signal);
257
+ if (!isCount(reply.status))
258
+ throw new Error(`The transport gave the request ${what} a status that is not a number.`);
259
+ const left = wholeNumber(header(reply, "x-ratelimit-remaining"));
260
+ if (left !== null)
261
+ remaining = left;
262
+ if (isRateRefusal(reply)) {
263
+ rateRefusals += 1;
264
+ blockedUntil = Math.max(blockedUntil, clock.now() + rateWaitMs(reply, strikes));
265
+ strikes += 1;
266
+ return { status: reply.status, body: reply.body ?? null, cached: false };
267
+ }
268
+ strikes = 0;
269
+ if (left === 0)
270
+ blockedUntil = Math.max(blockedUntil, clock.now() + rateWaitMs(reply, 0));
271
+ if (read && reply.status === 304 && stored !== undefined) {
272
+ notModified += 1;
273
+ cache.delete(url);
274
+ cache.set(url, stored);
275
+ return { status: 200, body: structuredClone(stored.body), cached: true };
276
+ }
277
+ if (read && cacheEntries > 0) {
278
+ const etag = header(reply, "etag");
279
+ if (reply.status === 200 && etag !== undefined && etag.length > 0) {
280
+ cache.delete(url);
281
+ cache.set(url, { etag, body: structuredClone(reply.body ?? null) });
282
+ while (cache.size > cacheEntries)
283
+ cache.delete(cache.keys().next().value);
284
+ }
285
+ else {
286
+ cache.delete(url);
287
+ }
288
+ }
289
+ return { status: reply.status, body: reply.body ?? null, cached: false };
290
+ },
291
+ state() {
292
+ return { sent, notModified, rateRefusals, cacheEntries: cache.size, remaining, blockedForMs: Math.max(0, blockedUntil - clock.now()) };
293
+ },
294
+ };
295
+ }
296
+ //# sourceMappingURL=rest.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rest.js","sourceRoot":"","sources":["../src/rest.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package",
3
3
  "name": "@procedures/gh",
4
- "version": "0.55.0",
5
- "description": "The GitHub CLI as procedures: pull requests, workflow runs and artifacts, releases, issues and the GitHub API, with an argument list and no shell.",
4
+ "version": "0.55.1",
5
+ "description": "The GitHub CLI as procedures: pull requests, workflow runs and artifacts, releases, issues and the GitHub API, with an argument list and no shell. Also a REST client with conditional requests and the limits of the rate.",
6
6
  "keywords": [
7
7
  "procedures",
8
8
  "github",
@@ -53,11 +53,11 @@
53
53
  "./dist/register.js"
54
54
  ],
55
55
  "devDependencies": {
56
- "typescript": "^7.0.2",
57
- "@types/node": "^22.0.0",
58
56
  "@mark1russell7/cue": "^2.1.1",
59
- "vitest": "^5.0.3",
60
- "@procedures/fs": "0.55.0"
57
+ "@procedures/fs": "0.55.1",
58
+ "@types/node": "^22.0.0",
59
+ "typescript": "^7.0.2",
60
+ "vitest": "^5.0.3"
61
61
  },
62
62
  "engines": {
63
63
  "node": ">=22"
@@ -66,8 +66,10 @@
66
66
  "procedures": "./dist/register.js"
67
67
  },
68
68
  "dependencies": {
69
- "@procedures/client": "0.55.0",
70
- "@procedures/shell": "0.55.0",
69
+ "@mark1russell7/service": "^0.8.6",
70
+ "@procedures/client": "0.55.1",
71
+ "@procedures/http": "0.55.1",
72
+ "@procedures/shell": "0.55.1",
71
73
  "zod": "^4.6.5"
72
74
  },
73
75
  "publishConfig": {
package/src/index.ts CHANGED
@@ -1,3 +1,5 @@
1
1
  // The root registers nothing: import "<package>/register", or give the module to createRegistry()
2
2
  export * from "./module.js";
3
3
  export * from "./types.js";
4
+ // The REST client: conditional requests and the limits of the rate, with no process for each request
5
+ export * from "./rest.js";
package/src/rest.ts ADDED
@@ -0,0 +1,396 @@
1
+ /**
2
+ * A client of the REST API of GitHub for a program that sends many requests: a dispatcher, a
3
+ * crawler or a loop that polls. It sends each request with `@procedures/http`, not with the `gh`
4
+ * CLI, so a request starts no process.
5
+ *
6
+ * - A GET with a stored answer is a conditional request. GitHub does not count an answer 304 for
7
+ * the primary limit, and the client then gives the stored answer.
8
+ * - A sliding window of `@mark1russell7/service/limiter` holds the secondary limit: 900 points in
9
+ * each minute. A GET costs 1 point, and a POST, a PUT, a PATCH or a DELETE costs 5 points.
10
+ * - A request that changes data waits one second after the request before it.
11
+ * - After a refusal for the rate, the client sends no request until the time that GitHub gives.
12
+ */
13
+
14
+ import type { Cancel, Clock } from "@mark1russell7/service/clock";
15
+ import { realClock } from "@mark1russell7/service/clock";
16
+ import { keyedLimiter, memoryStore, retryAfterSeconds, type LocalLimiter } from "@mark1russell7/service/limiter";
17
+ import { httpRequest } from "@procedures/http";
18
+
19
+ /** The methods of a REST request. */
20
+ export type RestMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
21
+
22
+ /** One request to the REST API. */
23
+ export interface RestRequest {
24
+ readonly method: RestMethod;
25
+ /** This path is the endpoint after the base URL, for example `/repos/octo/app/actions/runs`. It has no query. */
26
+ readonly path: string;
27
+ /** These fields are the query of the URL. The client sorts them, so one query has one URL. */
28
+ readonly query?: Readonly<Record<string, string>> | undefined;
29
+ /** This value is the JSON body. `null` and `undefined` are no body, and a GET has no body. */
30
+ readonly body?: unknown;
31
+ }
32
+
33
+ /** The answer to one request. */
34
+ export interface RestAnswer {
35
+ /** This number is the status of the answer. A 304 with a stored answer gives 200. */
36
+ readonly status: number;
37
+ /** This value is the JSON body, or `null` for an empty body. */
38
+ readonly body: unknown;
39
+ /** This field is `true` when the body is the stored answer of an answer 304. */
40
+ readonly cached: boolean;
41
+ }
42
+
43
+ /** One exchange that a transport sends. */
44
+ export interface RestExchange {
45
+ readonly method: RestMethod;
46
+ readonly url: string;
47
+ readonly headers: Readonly<Record<string, string>>;
48
+ /** This value is the JSON body, or `undefined` for no body. */
49
+ readonly body: unknown;
50
+ }
51
+
52
+ /** The reply of a transport: the status, the headers and the JSON body. */
53
+ export interface RestReply {
54
+ readonly status: number;
55
+ readonly headers: Readonly<Record<string, string | readonly string[]>>;
56
+ readonly body: unknown;
57
+ }
58
+
59
+ /** A transport sends one exchange. It throws only when it gets no reply: each status is a reply. */
60
+ export type RestTransport = (exchange: RestExchange, signal: AbortSignal) => Promise<RestReply>;
61
+
62
+ /** The options of a REST client. */
63
+ export interface RestClientOptions {
64
+ /** This value is the token, or a function that gives it before each request. A token of a GitHub App changes each hour. */
65
+ readonly token: string | (() => string | Promise<string>);
66
+ /** This URL is the base of the API. The default is `https://api.github.com`. */
67
+ readonly baseUrl?: string | undefined;
68
+ /** This text is the version of the API. The default is `2022-11-28`. */
69
+ readonly apiVersion?: string | undefined;
70
+ /** This text is the `user-agent` header. The default is `procedures-gh`. */
71
+ readonly userAgent?: string | undefined;
72
+ /** This count is the points of the secondary limit in each minute. The default is 900. */
73
+ readonly pointsPerMinute?: number | undefined;
74
+ /** This count is the most stored answers. The default is 1,000, and 0 stores no answer. */
75
+ readonly cacheEntries?: number | undefined;
76
+ /** This time is the longest wait of a request for the limits, in milliseconds. The default is 60,000. */
77
+ readonly maxWaitMs?: number | undefined;
78
+ /** This transport sends each exchange. The default sends it with `httpRequest` of `@procedures/http`. */
79
+ readonly transport?: RestTransport | undefined;
80
+ /** This clock gives the monotonic time and the timers. The default is the clock of the platform. */
81
+ readonly clock?: Clock | undefined;
82
+ }
83
+
84
+ /** The counts of a REST client, for a log or a measure. */
85
+ export interface RestClientState {
86
+ /** This count is the exchanges that the transport sent. */
87
+ readonly sent: number;
88
+ /** This count is the answers 304 that gave a stored answer. */
89
+ readonly notModified: number;
90
+ /** This count is the refusals for the rate. */
91
+ readonly rateRefusals: number;
92
+ /** This count is the stored answers. */
93
+ readonly cacheEntries: number;
94
+ /** This number is the last `x-ratelimit-remaining` of GitHub, or `null` before it. */
95
+ readonly remaining: number | null;
96
+ /** This time is the wait until the client sends again after a refusal for the rate, in milliseconds. */
97
+ readonly blockedForMs: number;
98
+ }
99
+
100
+ /** A client of the REST API of GitHub. */
101
+ export interface RestClient {
102
+ /** This method sends one request. It throws when it gets no answer or when the wait for a limit is too long. */
103
+ request(request: RestRequest, signal: AbortSignal): Promise<RestAnswer>;
104
+ /** This method gives the counts of the client. */
105
+ state(): RestClientState;
106
+ }
107
+
108
+ /** This error tells that a request did not start because the wait for a limit is longer than `maxWaitMs`. */
109
+ export class RestRateError extends Error {
110
+ override readonly name = "RestRateError";
111
+ /** This time is the wait until the request can start, in milliseconds. */
112
+ readonly retryAfterMs: number;
113
+ constructor(message: string, retryAfterMs: number) {
114
+ super(message);
115
+ this.retryAfterMs = retryAfterMs;
116
+ }
117
+ }
118
+
119
+ /** The base URL of the REST API of GitHub. */
120
+ export const GITHUB_API_URL: string = "https://api.github.com";
121
+ /** The version of the REST API that the client asks for. */
122
+ export const GITHUB_API_VERSION: string = "2022-11-28";
123
+ /** The points of the secondary limit of GitHub in each minute. */
124
+ export const GITHUB_POINTS_PER_MINUTE: number = 900;
125
+ /** The points of a GET, and of a request that changes data. */
126
+ export const GITHUB_POINTS: { readonly read: number; readonly write: number } = { read: 1, write: 5 };
127
+ /** The wait after a refusal for the rate with no time in its headers: one minute, then two times longer, to 16 minutes. */
128
+ export const RATE_WAIT_MS: { readonly first: number; readonly last: number } = { first: 60_000, last: 960_000 };
129
+ /** The longest wait that the headers of an answer can set: one hour and one minute. */
130
+ export const LONGEST_RATE_WAIT_MS: number = 3_660_000;
131
+
132
+ const METHODS: readonly string[] = ["GET", "POST", "PUT", "PATCH", "DELETE"];
133
+ const isCount = (value: unknown): value is number => typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
134
+
135
+ /** This function gives one header of a reply with its name in lowercase, or `undefined`. */
136
+ function header(reply: RestReply, name: string): string | undefined {
137
+ for (const [key, value] of Object.entries(reply.headers)) {
138
+ if (key.toLowerCase() !== name) continue;
139
+ return typeof value === "string" ? value : value.join(", ");
140
+ }
141
+ return undefined;
142
+ }
143
+
144
+ /** This function reads a whole number of 0 or more from a header, or gives `null`. */
145
+ function wholeNumber(text: string | undefined): number | null {
146
+ if (text === undefined || !/^\s*\d{1,15}\s*$/.test(text)) return null;
147
+ return Number(text.trim());
148
+ }
149
+
150
+ /** This function examines the path of a request. A path that can leave its endpoint is refused. */
151
+ function checkedPath(path: unknown): string {
152
+ if (typeof path !== "string" || !path.startsWith("/") || path.includes("//") || /[?#\\\s\u0000-\u001f\u007f]/.test(path)) {
153
+ throw new TypeError("The path of a REST request is not a path that starts with / and has no query.");
154
+ }
155
+ const segments = path.split("/").slice(1);
156
+ if (segments.some((segment) => /^(\.|%2e){1,2}$/i.test(segment))) {
157
+ throw new TypeError("The path of a REST request has a segment . or ..");
158
+ }
159
+ return path;
160
+ }
161
+
162
+ /**
163
+ * This function decides the wait after a refusal for the rate, in milliseconds, by the rules of
164
+ * GitHub. A `retry-after` header gives the wait. With `x-ratelimit-remaining` 0, the wait ends at
165
+ * `x-ratelimit-reset`, on the time of the `date` header. Else the wait is one minute, two times
166
+ * longer for each refusal in a sequence, to 16 minutes.
167
+ */
168
+ export function rateWaitMs(reply: RestReply, strikes: number): number {
169
+ const retryAfter = wholeNumber(header(reply, "retry-after"));
170
+ if (retryAfter !== null) return Math.min(retryAfter * 1000, LONGEST_RATE_WAIT_MS);
171
+ const remaining = wholeNumber(header(reply, "x-ratelimit-remaining"));
172
+ const reset = wholeNumber(header(reply, "x-ratelimit-reset"));
173
+ const date = Date.parse(header(reply, "date") ?? "");
174
+ if (remaining === 0 && reset !== null && Number.isFinite(date)) {
175
+ return Math.min(Math.max(reset * 1000 - date, 0) + 1000, LONGEST_RATE_WAIT_MS);
176
+ }
177
+ return Math.min(RATE_WAIT_MS.first * 2 ** Math.max(strikes, 0), RATE_WAIT_MS.last);
178
+ }
179
+
180
+ /**
181
+ * This function tells if a reply is a refusal for the rate. GitHub refuses with 429, or with 403
182
+ * and one of these: `x-ratelimit-remaining` 0, a `retry-after` header, or a message about a rate limit.
183
+ */
184
+ export function isRateRefusal(reply: RestReply): boolean {
185
+ if (reply.status === 429) return true;
186
+ if (reply.status !== 403) return false;
187
+ if (wholeNumber(header(reply, "x-ratelimit-remaining")) === 0) return true;
188
+ if (header(reply, "retry-after") !== undefined) return true;
189
+ const message = typeof reply.body === "object" && reply.body !== null ? (reply.body as { message?: unknown }).message : undefined;
190
+ return typeof message === "string" && /rate limit/i.test(message);
191
+ }
192
+
193
+ /**
194
+ * The default transport: `httpRequest` of `@procedures/http`, with its host policy and its limits of size and time.
195
+ * A redirect to another origin does not send the token. The transport parses the JSON itself, so an error text has no part of the body.
196
+ */
197
+ export const httpTransport: RestTransport = async (exchange, signal) => {
198
+ const reply = await httpRequest(
199
+ { method: exchange.method, url: exchange.url, headers: { ...exchange.headers }, json: exchange.body, responseType: "text" },
200
+ { signal },
201
+ );
202
+ const text = typeof reply.body === "string" ? reply.body : "";
203
+ let body: unknown = null;
204
+ if (text.trim().length > 0) {
205
+ try {
206
+ body = JSON.parse(text);
207
+ } catch {
208
+ throw new Error(`The answer ${reply.status} to ${exchange.method} ${new URL(exchange.url).pathname.slice(0, 200)} is not JSON.`);
209
+ }
210
+ }
211
+ return { status: reply.status, headers: reply.headers, body };
212
+ };
213
+
214
+ /** This function waits on the clock. The signal ends the wait with an error. */
215
+ function pause(clock: Clock, ms: number, signal: AbortSignal, what: string): Promise<void> {
216
+ // A wait on a clock with a signal has no function in @mark1russell7/algebra/clock yet. This copy goes when it has one.
217
+ return new Promise<void>((resolve, reject) => {
218
+ if (signal.aborted) {
219
+ reject(new Error(`The signal stopped the request ${what}.`));
220
+ return;
221
+ }
222
+ const stop = (): void => {
223
+ cancel();
224
+ reject(new Error(`The signal stopped the request ${what}.`));
225
+ };
226
+ const cancel: Cancel = clock.after(ms, () => {
227
+ signal.removeEventListener("abort", stop);
228
+ resolve();
229
+ });
230
+ signal.addEventListener("abort", stop, { once: true });
231
+ });
232
+ }
233
+
234
+ interface Stored {
235
+ readonly etag: string;
236
+ readonly body: unknown;
237
+ }
238
+
239
+ /**
240
+ * This function makes a client of the REST API of GitHub. It refuses options that are not valid with
241
+ * a `RangeError` or a `TypeError`. No error text of the client has the token or the body of an answer.
242
+ */
243
+ export function createRestClient(options: RestClientOptions): RestClient {
244
+ const base = new URL(options.baseUrl ?? GITHUB_API_URL);
245
+ if (base.protocol !== "https:" || base.search !== "" || base.hash !== "" || base.username !== "" || base.password !== "") {
246
+ throw new TypeError("The base URL of a REST client is not an https URL with no query and no credential.");
247
+ }
248
+ const basePath = base.pathname.replace(/\/+$/, "");
249
+ const apiVersion = options.apiVersion ?? GITHUB_API_VERSION;
250
+ const userAgent = options.userAgent ?? "procedures-gh";
251
+ for (const [name, value] of [["version", apiVersion], ["user agent", userAgent]] as const) {
252
+ if (typeof value !== "string" || value.length === 0 || /[\r\n]/.test(value)) {
253
+ throw new TypeError(`The ${name} of a REST client is not a text with a character and no line break.`);
254
+ }
255
+ }
256
+ const pointsPerMinute = options.pointsPerMinute ?? GITHUB_POINTS_PER_MINUTE;
257
+ if (!Number.isSafeInteger(pointsPerMinute) || pointsPerMinute < GITHUB_POINTS.write) {
258
+ throw new RangeError(`The points of a REST client in each minute are not a whole number of ${GITHUB_POINTS.write} or more.`);
259
+ }
260
+ const cacheEntries = options.cacheEntries ?? 1000;
261
+ if (!isCount(cacheEntries)) throw new RangeError("The stored answers of a REST client are not a whole number of 0 or more.");
262
+ const maxWaitMs = options.maxWaitMs ?? 60_000;
263
+ if (!isCount(maxWaitMs)) throw new RangeError("The longest wait of a REST client is not a whole number of milliseconds of 0 or more.");
264
+ if (typeof options.token !== "string" && typeof options.token !== "function") {
265
+ throw new TypeError("The token of a REST client is not a text or a function.");
266
+ }
267
+ const transport = options.transport ?? httpTransport;
268
+ const clock = options.clock ?? realClock;
269
+ const store = memoryStore(clock);
270
+ const points: LocalLimiter = keyedLimiter(
271
+ "github-points",
272
+ { key: "token", policy: { type: "sliding-window", limit: { value: pointsPerMinute, unit: "count" }, window: { value: 60, unit: "s" } }, store: { kind: "local" }, evict: { maxKeys: { value: 1, unit: "count" } } },
273
+ store,
274
+ );
275
+ const writes: LocalLimiter = keyedLimiter(
276
+ "github-writes",
277
+ { key: "token", policy: { type: "sliding-window", limit: { value: 1, unit: "count" }, window: { value: 1, unit: "s" } }, store: { kind: "local" }, evict: { maxKeys: { value: 1, unit: "count" } } },
278
+ store,
279
+ );
280
+ const cache = new Map<string, Stored>();
281
+ let blockedUntil = Number.NEGATIVE_INFINITY;
282
+ let strikes = 0;
283
+ let remaining: number | null = null;
284
+ let sent = 0;
285
+ let notModified = 0;
286
+ let rateRefusals = 0;
287
+
288
+ const token = async (): Promise<string> => {
289
+ const value = typeof options.token === "string" ? options.token : await options.token();
290
+ if (typeof value !== "string" || value.length === 0 || /[\s]/.test(value)) {
291
+ throw new TypeError("The token of a REST client is not a text with a character and no space or line break.");
292
+ }
293
+ return value;
294
+ };
295
+
296
+ /** This function waits until the limiter permits a take, or throws when the wait passes the deadline. */
297
+ const admit = async (limiter: LocalLimiter, units: number, deadline: number, signal: AbortSignal, what: string): Promise<void> => {
298
+ for (;;) {
299
+ const decision = limiter.tryTakeSync("token", units);
300
+ if (decision.allowed) return;
301
+ if (decision.cause === "capacity") throw new RangeError(`The request ${what} costs more points than a minute has.`);
302
+ const wait = Math.max(1, retryAfterSeconds(decision.retryAfter)) * 1000;
303
+ if (clock.now() + wait > deadline) throw new RestRateError(`The request ${what} waits ${wait} ms for the limit of the rate, more than its longest wait.`, wait);
304
+ await pause(clock, wait, signal, what);
305
+ }
306
+ };
307
+
308
+ /** This function waits until the block of a refusal for the rate ends, or throws when the block passes the deadline. */
309
+ const unblocked = async (deadline: number, signal: AbortSignal, what: string): Promise<void> => {
310
+ for (;;) {
311
+ const blocked = Math.ceil(blockedUntil - clock.now());
312
+ if (blocked <= 0) return;
313
+ if (clock.now() + blocked > deadline) {
314
+ throw new RestRateError(`GitHub refused a request for the rate. The request ${what} waits ${blocked} ms, more than its longest wait.`, blocked);
315
+ }
316
+ await pause(clock, blocked, signal, what);
317
+ }
318
+ };
319
+
320
+ const urlOf = (request: RestRequest): string => {
321
+ const url = new URL(base.origin);
322
+ url.pathname = basePath + checkedPath(request.path);
323
+ const query = request.query ?? {};
324
+ if (typeof query !== "object" || query === null) throw new TypeError("The query of a REST request is not a record of texts.");
325
+ for (const key of Object.keys(query).sort()) {
326
+ const value = query[key];
327
+ if (typeof value !== "string") throw new TypeError(`The query field ${JSON.stringify(key)} of a REST request is not a text.`);
328
+ url.searchParams.append(key, value);
329
+ }
330
+ return url.href;
331
+ };
332
+
333
+ return {
334
+ async request(request, signal) {
335
+ if (typeof request !== "object" || request === null || !METHODS.includes(request.method)) {
336
+ throw new TypeError("The method of a REST request is not GET, POST, PUT, PATCH or DELETE.");
337
+ }
338
+ const what = `${request.method} ${typeof request.path === "string" ? request.path.slice(0, 200) : "?"}`;
339
+ const url = urlOf(request);
340
+ const read = request.method === "GET";
341
+ const body = request.body === null ? undefined : request.body;
342
+ if (read && body !== undefined) throw new TypeError(`The request ${what} is a GET with a body.`);
343
+ if (signal.aborted) throw new Error(`The signal stopped the request ${what}.`);
344
+ const deadline = clock.now() + maxWaitMs;
345
+ await unblocked(deadline, signal, what);
346
+ if (!read) await admit(writes, 1, deadline, signal, what);
347
+ await admit(points, read ? GITHUB_POINTS.read : GITHUB_POINTS.write, deadline, signal, what);
348
+ // A refusal for the rate can come during the waits for the limiters, so the client examines the block again.
349
+ await unblocked(deadline, signal, what);
350
+
351
+ const headers: Record<string, string> = {
352
+ accept: "application/vnd.github+json",
353
+ authorization: `Bearer ${await token()}`,
354
+ "user-agent": userAgent,
355
+ "x-github-api-version": apiVersion,
356
+ };
357
+ const stored = read && cacheEntries > 0 ? cache.get(url) : undefined;
358
+ if (stored !== undefined) headers["if-none-match"] = stored.etag;
359
+ sent += 1;
360
+ const reply = await transport({ method: request.method, url, headers, body }, signal);
361
+ if (!isCount(reply.status)) throw new Error(`The transport gave the request ${what} a status that is not a number.`);
362
+
363
+ const left = wholeNumber(header(reply, "x-ratelimit-remaining"));
364
+ if (left !== null) remaining = left;
365
+ if (isRateRefusal(reply)) {
366
+ rateRefusals += 1;
367
+ blockedUntil = Math.max(blockedUntil, clock.now() + rateWaitMs(reply, strikes));
368
+ strikes += 1;
369
+ return { status: reply.status, body: reply.body ?? null, cached: false };
370
+ }
371
+ strikes = 0;
372
+ if (left === 0) blockedUntil = Math.max(blockedUntil, clock.now() + rateWaitMs(reply, 0));
373
+
374
+ if (read && reply.status === 304 && stored !== undefined) {
375
+ notModified += 1;
376
+ cache.delete(url);
377
+ cache.set(url, stored);
378
+ return { status: 200, body: structuredClone(stored.body), cached: true };
379
+ }
380
+ if (read && cacheEntries > 0) {
381
+ const etag = header(reply, "etag");
382
+ if (reply.status === 200 && etag !== undefined && etag.length > 0) {
383
+ cache.delete(url);
384
+ cache.set(url, { etag, body: structuredClone(reply.body ?? null) });
385
+ while (cache.size > cacheEntries) cache.delete(cache.keys().next().value!);
386
+ } else {
387
+ cache.delete(url);
388
+ }
389
+ }
390
+ return { status: reply.status, body: reply.body ?? null, cached: false };
391
+ },
392
+ state() {
393
+ return { sent, notModified, rateRefusals, cacheEntries: cache.size, remaining, blockedForMs: Math.max(0, blockedUntil - clock.now()) };
394
+ },
395
+ };
396
+ }