- name
- github-graphql
- description
- GitHub GraphQL API 最佳實踐指南。當需要使用 GraphQL 查詢使用者資料、處理 cursor pagination、計算 rate limit、或除錯 GraphQL errors 時使用。
# GitHub GraphQL API Best Practices Guide
## Rate Limiting(Points 系統)
GitHub GraphQL API 使用 **points 系統**,不是 REST 的 request count。
### Primary Rate Limits
| 認證方式 | Points/hr |
|----------|-----------|
| Personal Access Token | 5,000 |
| GitHub Enterprise Cloud | 10,000 |
| GitHub App(非 Enterprise) | 5,000 base + 50/repo + 50/user(cap 12,500) |
| GitHub Actions | 1,000/repo(Enterprise 15,000) |
### Secondary Rate Limits(每分鐘)
| 端點 | 限制 |
|------|------|
| GraphQL | 2,000 points/min |
| REST | 900 points/min |
| 並發請求 | 最多 100 個 |
Secondary limit 的計算不同於 primary:
- **Query(無 mutation)**:1 point
- **Query with mutation**:5 points
### 查詢 Rate Limit 狀態
```typescript
import { graphql } from "@octokit/graphql";
const graphqlWithAuth = graphql.defaults({
headers: { authorization: `token ${process.env.GITHUB_TOKEN}` },
});
const { rateLimit } = await graphqlWithAuth<{
rateLimit: { limit: number; cost: number; remaining: number; resetAt: string };
}>(`
query {
rateLimit {
limit
cost
remaining
resetAt
nodeCount
}
}
`);
console.log(`Cost: ${rateLimit.cost}, Remaining: ${rateLimit.remaining}`);
```
Response headers 也有:
- `x-ratelimit-limit`:最大 points/hr
- `x-ratelimit-remaining`:剩餘 points
- `x-ratelimit-used`:已用 points
- `x-ratelimit-reset`:重置時間(UTC epoch)
## Query Cost 計算
**公式**:加總每個 connection 需要的 requests,每個 request 假設達到 `first`/`last` 上限,除以 100 後取最近整數,最低 1 point。
### 範例
```graphql
query {
search(query: "location:Taiwan", type: USER, first: 100) { # 100/100 = 1
nodes {
... on User {
repositories(first: 10) { # 100 * 10 / 100 = 10
nodes { name }
}
}
}
}
}
# 總 cost = 1 + 10 = 11 points
```
**最佳化原則**:減少巢狀 connection 的 `first` 值可大幅降低 cost。
## Node Limits
- 單次查詢最多 **500,000 nodes**
- `first`/`last` 值必須在 **1–100** 之間
- Node 計算方式:各層 connection 的 `first`/`last` **相乘**
- 50 repos × 10 issues = 550 nodes(50 + 50×10)
## Search API(type: USER)
### 基本查詢
```typescript
import { graphql } from "@octokit/graphql";
const graphqlWithAuth = graphql.defaults({
headers: { authorization: `token ${process.env.GITHUB_TOKEN}` },
});
interface SearchUsersResult {
search: {
userCount: number;
pageInfo: { hasNextPage: boolean; endCursor: string | null };
nodes: Array<{
login: string;
name: string | null;
followers: { totalCount: number };
repositories: { totalCount: number };
contributionsCollection: {
contributionCalendar: { totalContributions: number };
};
}>;
};
}
const SEARCH_USERS_QUERY = `
query SearchUsers($query: String!, $first: Int!, $after: String) {
search(query: $query, type: USER, first: $first, after: $after) {
userCount
pageInfo {
hasNextPage
endCursor
}
nodes {
... on User {
login
name
followers { totalCount }
repositories(ownerAffiliations: OWNER) { totalCount }
contributionsCollection {
contributionCalendar { totalContributions }
}
}
}
}
rateLimit { cost remaining resetAt }
}
`;
const result = await graphqlWithAuth<SearchUsersResult>(SEARCH_USERS_QUERY, {
query: "location:Taiwan followers:>50",
first: 100,
after: null,
});
```
### Search Query 語法
- `location:Taiwan` — 位置
- `followers:>100` / `followers:50..200` — 追蹤者範圍
- `repos:>10` — repo 數
- `language:TypeScript` — 主要語言
- `type:user` / `type:org` — 帳號類型
- `created:>2020-01-01` — 建立日期
- 空格 = AND 組合
### 重要限制:1000 筆結果上限
Search API **硬性限制最多回傳 1000 筆結果**,無論 pagination 如何設定。這是 GitHub 後端限制,REST 和 GraphQL 皆然。
#### 突破 1000 限制的策略
```typescript
// 策略:用 followers 範圍切分查詢
const followerRanges = [
"followers:>1000",
"followers:501..1000",
"followers:201..500",
"followers:101..200",
"followers:51..100",
"followers:21..50",
"followers:11..20",
"followers:1..10",
];
async function searchAllUsers(location: string): Promise<User[]> {
const allUsers: User[] = [];
for (const range of followerRanges) {
const query = `location:${location} ${range}`;
const users = await paginateSearch(query);
allUsers.push(...users);
// 如果某個範圍還是 >1000,需要再細分
if (users.length >= 1000) {
console.warn(`Range "${range}" hit 1000 cap, needs finer splitting`);
}
}
return deduplicateByLogin(allUsers);
}
```
#### 自動細分範圍
```typescript
async function adaptiveSearch(
location: string,
minFollowers: number,
maxFollowers: number,
): Promise<User[]> {
const query = `location:${location} followers:${minFollowers}..${maxFollowers}`;
// 先查 userCount
const { search } = await graphqlWithAuth<SearchUsersResult>(SEARCH_USERS_QUERY, {
query,
first: 1,
after: null,
});
if (search.userCount <= 1000) {
return paginateSearch(query);
}
// 超過 1000,二分法細分
const mid = Math.floor((minFollowers + maxFollowers) / 2);
const lower = await adaptiveSearch(location, minFollowers, mid);
const upper = await adaptiveSearch(location, mid + 1, maxFollowers);
return [...lower, ...upper];
}
```
## Cursor-Based Pagination
### 完整分頁實作
```typescript
async function paginateSearch(searchQuery: string): Promise<User[]> {
const allUsers: User[] = [];
let cursor: string | null = null;
let hasNextPage = true;
while (hasNextPage) {
const result = await graphqlWithAuth<SearchUsersResult>(SEARCH_USERS_QUERY, {
query: searchQuery,
first: 100, // 永遠用最大值 100
after: cursor,
});
const { search, rateLimit } = result as any;
// 過濾 null nodes(Organization 不符合 User fragment 時會是 null)
const users = search.nodes.filter((node: any) => node !== null && node.login);
allUsers.push(...users);
hasNextPage = search.pageInfo.hasNextPage;
cursor = search.pageInfo.endCursor;
// 監控 rate limit
if (rateLimit.remaining < 100) {
const resetAt = new Date(rateLimit.resetAt);
const waitMs = resetAt.getTime() - Date.now() + 1000;
if (waitMs > 0) {
console.log(`Rate limit low (${rateLimit.remaining}), waiting ${waitMs}ms`);
await new Promise((resolve) => setTimeout(resolve, waitMs));
}
}
}
return allUsers;
}
```
### Pagination 要點
- **永遠用 `first: 100`**:減少 API 呼叫次數
- **Forward pagination**:`first` + `after` + `endCursor` + `hasNextPage`
- **Backward pagination**:`last` + `before` + `startCursor` + `hasPreviousPage`
- **`after: null`** 表示第一頁
- **PageInfo 四欄位**:`endCursor`、`startCursor`、`hasNextPage`、`hasPreviousPage`
## Batch Queries(批次查詢)
### 用 Alias 合併多個使用者查詢
```typescript
function buildBatchUserQuery(logins: string[]): string {
const fragments = logins.map(
(login, i) => `
user${i}: user(login: "${login}") {
login
name
followers { totalCount }
repositories(ownerAffiliations: OWNER) { totalCount }
contributionsCollection {
contributionCalendar { totalContributions }
}
}
`
);
return `query BatchUsers {
${fragments.join("\n")}
rateLimit { cost remaining resetAt }
}`;
}
// 每批最多 ~50 個使用者(避免超過 node limit 和 query 大小限制)
async function batchFetchUsers(logins: string[]): Promise<Map<string, User>> {
const BATCH_SIZE = 50;
const results = new Map<string, User>();
for (let i = 0; i < logins.length; i += BATCH_SIZE) {
const batch = logins.slice(i, i + BATCH_SIZE);
const query = buildBatchUserQuery(batch);
const data = await graphqlWithAuth(query);
for (let j = 0; j < batch.length; j++) {
const userData = (data as any)[`user${j}`];
if (userData) {
results.set(userData.login, userData);
}
}
}
return results;
}
```
### Batch Query Cost
Alias 查詢中,每個 `user(login:)` 是獨立 node request,cost = alias 數量 / 100(最低 1)。比 N 次獨立查詢(N points)便宜很多。
## Error Handling
### GraphQL Error 結構
```typescript
import { GraphqlResponseError } from "@octokit/graphql";
interface GraphQLError {
type: string; // "RATE_LIMITED" | "NOT_FOUND" | "FORBIDDEN" 等
message: string;
path?: string[];
locations?: Array<{ line: number; column: number }>;
}
async function safeGraphqlQuery<T>(query: string, variables: Record<string, unknown>): Promise<T | null> {
try {
return await graphqlWithAuth<T>(query, variables);
} catch (error) {
if (error instanceof GraphqlResponseError) {
// GraphQL 回傳了 data + errors
const errors = error.errors ?? [];
for (const err of errors) {
switch (err.type) {
case "RATE_LIMITED":
console.error("Rate limited, waiting...");
await waitForRateLimit();
return safeGraphqlQuery<T>(query, variables);
case "NOT_FOUND":
console.warn(`Resource not found: ${err.path?.join(".")}`);
return error.data as T; // partial data 仍可用
case "FORBIDDEN":
console.error(`Permission denied: ${err.message}`);
return null;
default:
console.error(`GraphQL error: ${err.type} - ${err.message}`);
}
}
}
// Network error 或其他
if ((error as any).status === 502) {
console.warn("GitHub 502, retrying...");
await new Promise((r) => setTimeout(r, 2000));
return safeGraphqlQuery<T>(query, variables);
}
throw error;
}
}
```
### 常見錯誤碼
| Error Type | 原因 | 處理方式 |
|-----------|------|---------|
| `RATE_LIMITED` | 超過 rate limit | 等待 `resetAt` 後重試 |
| `NOT_FOUND` | User/Repo 不存在 | 跳過,partial data 可能可用 |
| `FORBIDDEN` | Token 權限不足 | 檢查 scope |
| `MAX_NODE_LIMIT_EXCEEDED` | 超過 500K nodes | 減少 `first`/巢狀深度 |
| `QUERY_COMPLEXITY` | Query 太複雜 | 拆分 query |
| Network 502/503 | GitHub 暫時錯誤 | 指數退避重試 |
### Partial Data 處理
GraphQL 可能回傳 **data + errors 同時存在**。例如查詢 10 個 user,其中 1 個不存在:
```typescript
// response.data 有 9 個 user 的資料
// response.errors 有 1 個 NOT_FOUND
// 不要因為 errors 就丟掉全部 data!
```
## Authentication Scopes
### 本專案需要的最小 scope(Personal Access Token)
| Scope | 用途 |
|-------|------|
| `read:user` | 讀取使用者 profile(必要) |
| `user:email` | 讀取使用者 email(可選) |
| `repo`(public_repo) | 讀取 public repo 資料 |
| `read:org` | 讀取 organization 成員資料(可選) |
### Fine-Grained Personal Access Token(推薦)
Fine-grained token 可以更精確控制權限:
- **Account permissions** > **Followers**: Read-only
- **Repository permissions** > **Metadata**: Read-only
### 認證設定
```typescript
import { graphql } from "@octokit/graphql";
// 方式 1:直接設定
const graphqlWithAuth = graphql.defaults({
headers: {
authorization: `token ${process.env.GITHUB_TOKEN}`,
GitHubで見る