Skip to content

Practice Network

ChangYo edited this page Oct 26, 2026 · 3 revisions

分页、限流与错误

校园接口的差异主要集中在三处:翻页、限流和错误判断。这页归纳多个平台的通用模式,各平台页只写差异。

相关:Canvas、Timetable、Shuiyuan、Classroom-Video、Open API、选课社区、第二课堂、传承·交大。

1. 分页模型

平台 分页方式 关键字段 注意
Canvas Link 响应头 rel="next"、rel="last" per_page 被限制为 50;必须跟随 Link
Open API limit + start 或 pageToken nextToken、total start 和 pageToken 同时给时忽略 pageToken
数据资源 API first + offset first、offset、total offset 最大 200;first 是起始位置,默认 1
课堂视频 page.pageIndex + page.pageSize pageCount、records 空页可能返回 records: null;后续页不能用零总数清空
水源 more_topics_url 或 page more_topics_url、grouped_search_result URL 要补 .json;搜索用 page,主题流用 more URL
选课社区 page + page_size items、total、page、page_size 默认 20;数组参数重复 key
第二课堂 page + per_page data[] + 顶层 total 总数不在分页对象里
传承·交大 无分页 data.classes、data.unarchived 一次返回整门课,约 780 KB;按班级切片

Canvas:Link 头

function nextLink(header: string | null): string | null {
  const next = header
    ?.split(',')
    .find((part) => /rel="next"/.test(part))
    ?.match(/<([^>]+)>/)?.[1];
  if (!next) return null;
  const url = new URL(next, 'https://oc.sjtu.edu.cn/api/v1/');
  if (url.origin !== 'https://oc.sjtu.edu.cn' || !url.pathname.startsWith('/api/v1/')) {
    throw new Error('分页地址无效');
  }
  return url.href;
}

水源:more_topics_url

function nextTopicsPath(moreUrl: string | null): string | null {
  if (!moreUrl) return null;
  const url = new URL(moreUrl, 'https://shuiyuan.sjtu.edu.cn');
  if (url.origin !== 'https://shuiyuan.sjtu.edu.cn') throw new Error('分页地址无效');
  return `${url.pathname.replace(/\.json$/, '')}.json${url.search}`;
}

通用循环

async function listAll<T>(
  firstUrl: string,
  fetchPage: (url: string) => Promise<{ items: T[]; next: string | null }>,
): Promise<T[]> {
  let url: string | null = firstUrl;
  const visited = new Set<string>();
  const result: T[] = [];
  while (url) {
    if (visited.has(url)) throw new Error('服务端返回了重复分页');
    visited.add(url);
    const page = await fetchPage(url);
    result.push(...page.items);
    url = page.next;
  }
  return result;
}

三条通用规则:

  1. 不要自己拼 page,除非文档明确说可以。
  2. 记录已访问 URL,防止重复分页死循环。
  3. 限制总页数/总条数,防止异常响应把内存打爆。

2. 空响应与错误

校园接口比 500 更容易误判的情况,是「看起来成功但没有数据」。

表现 可能含义 处理
HTTP 200 + entities: [] 真的没有数据 显示空状态,保留旧缓存
HTTP 200 + errno != 0 业务错误 按 errno 处理,不要看 HTTP 200 就当成功
HTTP 200 + records: null 课堂视频空页 只在第一页且明确零记录时当空;否则报错
HTTP 200 + success: false 传承业务失败 读 message,不要看 HTTP
HTTP 200 + code: 401 第二课堂未登录 按登录失效处理
HTTP 200 + HTML 登录页/错误页 按登录失效或接口变化处理
HTTP 302 登录失效/重定向 不要自动跟随到外域;重新登录
HTTP 401 未认证或无权访问 先探活;区分全局失效和单资源无权限
HTTP 403 禁止 可能是权限、scope、CSRF、重复操作、限流
HTTP 404 资源/功能不存在 分支跳过;不要当整站失败
HTTP 429 限流 读 Retry-After,退避

原则:同步失败不清空缓存。空响应只有在你能证明「服务端明确说零条」时才当空;否则保留旧数据并标错误。


3. 错误信封

平台 成功 业务错误 备注
Open API errno: 0 errno != 0 + error 403 常见 10002
数据资源 API errno: 0 errno != 0 1002 权限、1004 访问限制;没有 1007
InfoPlus errno: 0 errno + ecode + error 语法正确也可能 HTTP 200 + errno
Canvas HTTP 2xx + JSON HTTP 4xx/5xx + JSON errors 401 body 是中文
水源 HTTP 2xx + JSON HTTP 4xx + errors[] 403 多义
课堂视频 ok: true / code: null ok: false / dataOk: false / status != 200 未登录可能是空列表
选课社区 HTTP 2xx + JSON HTTP 4xx + {"error": "..."} 401/403/429 语义不同
第二课堂 code: 200 code: 401/404 + message_key HTTP 始终 200
传承·交大 success: true success: false + message HTTP 401 也是这个信封

写一个统一的 ApiError:

class ApiError extends Error {
  constructor(
    message: string,
    readonly status: number,
    readonly code?: string | number,
    readonly retryable = false,
  ) {
    super(message);
  }
}

4. 重试与退避

什么该重试

情况 重试 说明
网络超时、连接重置 是 1 次,250ms 起
408、500、502、503、504 是 1–2 次,指数退避
429 是 优先 Retry-After
401 / 403 否 重新登录或检查权限,不要盲目重试
400 / 404 / 422 否 参数/资源错误,重试无意义
写操作超时 谨慎 可能已经成功;用幂等键/查询确认

退避

async function withRetry<T>(
  task: () => Promise<T>,
  options: { attempts?: number; baseMs?: number } = {},
): Promise<T> {
  const attempts = options.attempts ?? 2;
  const baseMs = options.baseMs ?? 250;
  let lastError: unknown;
  for (let attempt = 0; attempt < attempts; attempt++) {
    try {
      return await task();
    } catch (error) {
      lastError = error;
      if (attempt === attempts - 1) break;
      const retryable = error instanceof ApiError && error.retryable;
      if (!retryable) break;
      await new Promise((resolve) => setTimeout(resolve, baseMs * 2 ** attempt + Math.random() * 100));
    }
  }
  throw lastError;
}

限流

  • Canvas:X-Rate-Limit-Remaining 600,X-Request-Cost 按请求扣减。[实测]
  • 水源:没有暴露 X-RateLimit-*;触发时看 Retry-After。[实测]
  • 课堂视频:未观察到限流;不要压测。
  • 选课社区:投票有每日上限,超限 429;其他接口未观察到明确限流。[实测 2026-10-26]
  • 第二课堂、传承:未观察到明确限流;按小时级刷新,不要轮询。
  • 通用:同步多门课串行或 2–3 并发;不要每门课开 10 个请求。

5. 幂等与写操作

  • 点赞:重复点赞返回 403,视为「已赞」,不要报错。
  • 书签:创建前先查收藏列表,避免重复。
  • 发信:SMTP 超时不等于失败;用 Message-ID 去重,查已发送。
  • 回复/私信:客户端生成幂等键(如果站点支持);否则用户手动重试。
  • 写操作要有用户确认;不要在后台自动重放。

6. 可观测性

日志只记:

  • 平台、方法、路径(去掉 query 里的 token)
  • 状态码、业务错误码、耗时
  • 重试次数、最终结果
  • 请求 ID(如果响应有)

不要记:

  • Authorization、Cookie、User-Api-Key、签名 URL
  • 邮件正文、私信、成绩、表单字段
  • 完整响应体(除非脱敏)

7. 常见问题

  1. per_page 被静默限制:不要相信请求参数,跟 Link。
  2. 自己拼 page:可能和站点的 page 起点不一致(水源 page=1 是首页,more URL 从 page=0 起算)。
  3. 空页清空列表:后续页返回空/零总数时,不要清掉已取结果。
  4. 重复分页死循环:记录 URL。
  5. HTTP 200 就当成功:Open API/InfoPlus 的业务错误在 errno。
  6. 401 就清 Token:先探活,区分单资源无权限。
  7. 403 就重新授权:可能是 CSRF、scope、重复操作、限流。
  8. 写超时当失败:可能已经成功;先查状态。
  9. 并发轰炸:串行/小并发,读 Retry-After。
  10. 日志泄露凭据:路径 query、错误对象、堆栈都可能带 token。
  11. 只看 HTTP 状态:第二课堂 200 + code: 401,传承 200 + success: false,都要读 body。
  12. 把对象当数组:传承 /material 返回 {classes, unarchived},不是数组。
  13. 没有分页却按页拉:传承一次返回整门课,不要自己拼 page。

8. 证据

  • Canvas 分页/限流:[实测 2026-09-10]
  • 水源 more URL/搜索分页/403 多义:[实测 2026-09-15/21]
  • 课堂视频 records: null、分页:[实测 2026-10-06]
  • Open API/数据资源 API/InfoPlus 错误码与分页:[官方 2026-10-25]
  • 选课社区分页/错误码、第二课堂 total、传承无分页:[实测 2026-10-26]

变更记录

  • 2026-10-25:首版。抽取五个平台的分页、限流、错误模式。
  • 2026-10-26:补选课社区、第二课堂、传承·交大的分页与错误信封。

Clone this wiki locally