summaryrefslogtreecommitdiff
path: root/docs/handbook/ci/rate-limiting.md
blob: 4b349ee2b4442198ba70191bd7e6a39d60339d55 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
# Rate Limiting

## Overview

The CI system interacts heavily with the GitHub REST API for PR validation, commit
analysis, review management, and branch comparison. To prevent exhausting the
GitHub API rate limit (5,000 requests/hour for authenticated tokens), all API calls
are routed through `ci/github-script/withRateLimit.js`, which uses the
[Bottleneck](https://github.com/SGrondin/bottleneck) library for request throttling.

---

## Architecture

### Request Flow

```
┌──────────────────────────┐
│  CI Script               │
│  (lint-commits.js,       │
│   prepare.js, etc.)      │
└────────────┬─────────────┘
             │ github.rest.*
             ▼
┌──────────────────────────┐
│  withRateLimit wrapper   │
│  ┌──────────────────┐   │
│  │ allLimits         │   │  ← Bottleneck (maxConcurrent: 1, reservoir: dynamic)
│  │ (all requests)    │   │
│  └──────────────────┘   │
│  ┌──────────────────┐   │
│  │ writeLimits       │   │  ← Bottleneck (minTime: 1000ms) chained to allLimits
│  │ (POST/PUT/PATCH/  │   │
│  │  DELETE only)     │   │
│  └──────────────────┘   │
└────────────┬─────────────┘
             │
             ▼
┌──────────────────────────┐
│  GitHub REST API         │
│  api.github.com          │
└──────────────────────────┘
```

---

## Implementation

### Module Signature

```javascript
module.exports = async ({ github, core, maxConcurrent = 1 }, callback) => {
```

| Parameter       | Type     | Default | Description                          |
|----------------|----------|---------|--------------------------------------|
| `github`        | Object  | —       | Octokit instance from `@actions/github` |
| `core`          | Object  | —       | `@actions/core` for logging          |
| `maxConcurrent` | Number  | `1`     | Maximum concurrent API requests      |
| `callback`      | Function| —       | The script logic to execute          |

### Bottleneck Configuration

Two Bottleneck limiters are configured:

#### allLimits — Controls all requests

```javascript
const allLimits = new Bottleneck({
  maxConcurrent,
  reservoir: 0,  // Updated dynamically
})
```

- `maxConcurrent: 1` — Only one API request at a time (prevents burst usage)
- `reservoir: 0` — Starts empty; updated by `updateReservoir()` before first use

#### writeLimits — Additional throttle for mutative requests

```javascript
const writeLimits = new Bottleneck({ minTime: 1000 }).chain(allLimits)
```

- `minTime: 1000` — At least 1 second between write requests
- `.chain(allLimits)` — Write requests also go through the global limiter

---

## Request Classification

The Octokit `request` hook intercepts every API call and routes it through
the appropriate limiter:

```javascript
github.hook.wrap('request', async (request, options) => {
  // Bypass: different host (e.g., github.com for raw downloads)
  if (options.url.startsWith('https://github.com')) return request(options)

  // Bypass: rate limit endpoint (doesn't count against quota)
  if (options.url === '/rate_limit') return request(options)

  // Bypass: search endpoints (separate rate limit pool)
  if (options.url.startsWith('/search/')) return request(options)

  stats.requests++

  if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(options.method))
    return writeLimits.schedule(request.bind(null, options))
  else
    return allLimits.schedule(request.bind(null, options))
})
```

### Bypass Rules

| URL Pattern                    | Reason                                      |
|-------------------------------|---------------------------------------------|
| `https://github.com/*`        | Raw file downloads, not API calls           |
| `/rate_limit`                 | Meta-endpoint, doesn't count against quota  |
| `/search/*`                   | Separate rate limit pool (30 requests/min)  |

### Request Routing

| HTTP Method           | Limiter          | Throttle Rule                    |
|----------------------|------------------|----------------------------------|
| `GET`                | `allLimits`      | Concurrency-limited + reservoir  |
| `POST`               | `writeLimits`    | 1 second minimum + concurrency   |
| `PUT`                | `writeLimits`    | 1 second minimum + concurrency   |
| `PATCH`              | `writeLimits`    | 1 second minimum + concurrency   |
| `DELETE`             | `writeLimits`    | 1 second minimum + concurrency   |

---

## Reservoir Management

### Dynamic Reservoir Updates

The reservoir tracks how many API requests the script is allowed to make:

```javascript
async function updateReservoir() {
  let response
  try {
    response = await github.rest.rateLimit.get()
  } catch (err) {
    core.error(`Failed updating reservoir:\n${err}`)
    return
  }
  const reservoir = Math.max(0, response.data.resources.core.remaining - 1000)
  core.info(`Updating reservoir to: ${reservoir}`)
  allLimits.updateSettings({ reservoir })
}
```

### Reserve Buffer

The script always keeps **1,000 spare requests** for other concurrent jobs:

```javascript
const reservoir = Math.max(0, response.data.resources.core.remaining - 1000)
```

If the rate limit shows 3,500 remaining requests, the reservoir is set to 2,500.
If remaining is below 1,000, the reservoir is set to 0 (all requests will queue).

### Why 1,000?

Other GitHub Actions jobs running in parallel (status checks, deployment workflows,
external integrations) typically use fewer than 100 requests each. A 1,000-request
buffer provides ample headroom:

- Normal job: ~50–100 API calls
- 10 concurrent jobs: ~500–1,000 API calls
- Buffer: 1,000 requests — covers typical parallel workload

### Update Schedule

```javascript
await updateReservoir()  // Initial update before any work
const reservoirUpdater = setInterval(updateReservoir, 60 * 1000)  // Every 60 seconds
```

The reservoir is refreshed every minute to account for:
- Other jobs consuming requests in parallel
- Rate limit window resets (GitHub resets the limit every hour)

### Cleanup

```javascript
try {
  await callback(stats)
} finally {
  clearInterval(reservoirUpdater)
  core.notice(
    `Processed ${stats.prs} PRs, ${stats.issues} Issues, ` +
      `made ${stats.requests + stats.artifacts} API requests ` +
      `and downloaded ${stats.artifacts} artifacts.`,
  )
}
```

The interval is cleared in a `finally` block to prevent resource leaks even if
the callback throws an error.

---

## Statistics Tracking

The wrapper tracks four metrics:

```javascript
const stats = {
  issues: 0,
  prs: 0,
  requests: 0,
  artifacts: 0,
}
```

| Metric       | Incremented By                        | Purpose                          |
|-------------|---------------------------------------|----------------------------------|
| `requests`   | Every throttled API call              | Total API usage                  |
| `prs`        | Callback logic (PR processing)        | PRs analyzed                     |
| `issues`     | Callback logic (issue processing)     | Issues analyzed                  |
| `artifacts`  | Callback logic (artifact downloads)   | Artifacts downloaded             |

At the end of execution, a summary is logged:

```
Notice: Processed 1 PRs, 0 Issues, made 15 API requests and downloaded 0 artifacts.
```

---

## Error Handling

### Rate Limit API Failure

If the rate limit endpoint itself fails (network error, GitHub outage):

```javascript
try {
  response = await github.rest.rateLimit.get()
} catch (err) {
  core.error(`Failed updating reservoir:\n${err}`)
  return  // Keep retrying on next interval
}
```

The error is logged but does not crash the script. The reservoir retains its
previous value, and the next 60-second interval will try again.

### Exhausted Reservoir

When the reservoir reaches 0:
- All new requests queue in Bottleneck
- Requests wait until the next `updateReservoir()` call adds capacity
- If GitHub's rate limit has not reset, requests continue to queue
- The script may time out if the rate limit window hasn't reset

---

## GitHub API Rate Limits Reference

| Resource     | Limit                    | Reset Period |
|-------------|--------------------------|--------------|
| Core REST API| 5,000 requests/hour     | Rolling hour |
| Search API   | 30 requests/minute      | Rolling minute|
| GraphQL API  | 5,000 points/hour       | Rolling hour |

The `withRateLimit.js` module only manages the **Core REST API** limit. Search
requests bypass the throttle because they have a separate, lower limit that is
rarely a concern for CI scripts.

---

## Usage in CI Scripts

### Wrapping a Script

```javascript
const withRateLimit = require('./withRateLimit.js')

module.exports = async ({ github, core }) => {
  await withRateLimit({ github, core }, async (stats) => {
    // All github.rest.* calls here are automatically throttled

    const pr = await github.rest.pulls.get({
      owner: 'YongDo-Hyun',
      repo: 'Project-Tick',
      pull_number: 123,
    })
    stats.prs++

    // ... more API calls
  })
}
```

### Adjusting Concurrency

For scripts that can safely parallelize reads:

```javascript
await withRateLimit({ github, core, maxConcurrent: 5 }, async (stats) => {
  // Up to 5 concurrent GET requests
  // Write requests still have 1-second minimum spacing
})
```

---

## Best Practices

1. **Minimize API calls** — Use pagination efficiently, avoid redundant requests
2. **Prefer git over API** — For commit data, `get-pr-commit-details.js` uses git directly
   to bypass the 250-commit API limit and reduce API usage
3. **Use the `stats` object** — Track what the script does for observability
4. **Don't bypass the wrapper** — All API calls should go through the throttled Octokit instance
5. **Handle network errors** — The wrapper handles rate limit API failures, but callback
   scripts should handle their own API errors gracefully