moonhttp

    axios 风格的 MoonBit HTTP 客户端:实例与配置合并、四种请求体形态、流式与 SSE、自动重定向

    http
    client
    axios
    async
    sse
    networking
    Download zip
    Version
    0.1.0
    License
    Apache-2.0
    Last updated
    11 hours ago
    Downloads
    3

    Dependencies

    #moonhttp

    MoonBit 上的 HTTP 客户端。网络 I/O 交给官方异步库 moonbitlang/async,上层这套 API 负责配置、发请求与读响应:调第三方 REST API、上传文件、消费 SSE 流,一次配置就能发请求,不必先读懂底层传输的类型。

    它提供实例化配置与合并、request / stream / sse 三个入口、四种请求体形态、自动重定向、代理隧道、上传下载进度回调、请求与响应拦截器、取消请求、SSE 事件解析,以及一组按失败原因分类的错误码。API 语义参考 axios,代码为原创实现,未移植其源码。

    #安装

    moon add q2316367743/moonhttp

    moon.mod 里会记录:

    import { "q2316367743/moonhttp@0.1.0", }

    默认构建目标是 native。

    #快速开始

    ///|
    async fn main {
    // 实例上一次性配好 base_url 与公共头,之后的请求只写路径
    let api = @moonhttp.create(
    @moonhttp.Config::default()
    .with_base_url("https://api.github.com")
    .with_timeout(5_000)
    .with_common_header("Accept", "application/vnd.github+json"),
    )

    let res = api.request(
    @moonhttp.Config::new("/repos/moonbitlang/core").with_params({
    "per_page": 3,
    }),
    )

    println(res.status) // 200
    println(res.text()) // 响应体原文,按 response_encoding 解码(默认 UTF-8)
    println((try! res.json()).stringify()) // 要对象就 json()
    }

    覆盖更多场景的示例在 src/main/: 快速上手那段(文本响应、JSON、查询参数、实例派生、错误处理、拦截器)用 moon run src/main 执行(需要联网), 另有七个方向各一个可单独运行的包——basics(状态码 / 头 / 查询参数 / 编码 / 错误 / 超时)、 methods(请求方式 × 请求体形态 × 响应内容)、proxy、progress(上传下载进度与 5% 取消)、 redirect、interceptors、sse,跑法都是 moon run src/main/<方向>。

    #功能与用法

    #实例与配置合并

    配置分三层:内置默认值、实例默认值(create / Client::new 传入的)、本次请求。合并不是简单覆盖,None 表示「未提供」,一律回退:

    字段合并方式
    url / 请求方法 / 请求体只取请求级,实例默认值里的同名字段丢弃
    base_url / timeout / max_redirects / response_encoding / 进度回调 / cancel_token请求级优先,缺省回退实例默认值
    params / headers / auth / proxy逐层合并(头名大小写不敏感)
    validate_status / params_serializer请求级提供即整体接管

    // 从既有实例派生:继承默认值与拦截器,再叠加本次的配置
    let search = api.create(@moonhttp.Config::default().with_timeout(15_000))

    请求方法缺省时按「实例默认值 → GET」回退,api.defaults() 可以看实例当前的默认配置;状态码默认只认 2xx,想关掉校验就传一个恒真函数(with_validate_status(fn(_) { true }))。

    #三个入口

    入口拿到什么什么时候用
    client.request(config)Response,响应体已读全常规请求
    client.stream(config)StreamResponse,原始字节流下载、自己按块处理
    client.sse(config)SseStream,解析好的事件流消费 SSE

    三者的配置合并与状态码校验完全一致,区别只在响应体怎么读。

    #请求体:四种形态

    构建器发出去的内容自动补的 Content-Type
    with_data_from_str(s)s 的 UTF-8 字节,一个字节不改不补
    with_data_from_json(j)j.stringify() 之后的 JSON 文本application/json
    with_data_from_form(form)multipart/form-data 正文multipart/form-data; boundary=...
    with_data_from_urlencoded(j)a=1&b=2 形式application/x-www-form-urlencoded

    // JSON 请求体
    api.request(@moonhttp.Config::new("/users")
    .with_method(@moonhttp.Method::Post)
    .with_data_from_json({ "name": "moon" }))

    // 带文件的表单:文件按「字节 + 文件名」传入,库不读盘
    let form = @moonhttp.FormData::new()
    .append_text("title", "假期照片")
    .append_file("avatar", "a.png", bytes, content_type="image/png")
    api.request(@moonhttp.Config::new("/upload")
    .with_method(@moonhttp.Method::Post)
    .with_data_from_form(form))

    两点容易踩:「是不是 JSON」由你选的方法决定,不由值的类型决定——with_data_from_json("hi") 发出去的是带引号的 "hi",with_data_from_str("hi") 发出去的是裸 hi;自动补的 Content-Type 是「补默认值」,你自己设了就一个字节都不改。

    #读响应

    res.text() // 按 response_encoding(默认 UTF-8)解码成文本
    res.bytes() // 原样取出字节,不经过任何解码
    try! res.json() // 先按同一编码解码,再 @json.parse(失败抛 @json.ParseError)
    res.content_length() // 响应体字节数
    res.is_success() // 状态码是不是 2xx

    状态行与响应头在 res.status / res.status_text / res.headers 上。库不做自动解析:json() 必须显式调用,它也不看 Content-Type——能拿到 Response 说明 HTTP 这一层已经成功,文本要自己解析就不调它。

    #拦截器

    请求侧在发送前改配置,响应侧在拿到响应后改响应、或在失败时救错与重试。拦截器挂在实例上:

    let plain = @moonhttp.Client::new() // 给重试用的裸实例:不带这层拦截器,天然不会无限递归
    let client = @moonhttp.Client::new(
    interceptors~ = @moonhttp.Interceptors::new()
    // 请求侧:发送前改配置(加认证头、改地址、给所有请求注入公共 body 字段)
    .use_request(config => config.with_header("X-Token", "secret"))
    // 响应侧:原样返回就只是观察;要改就用 with_status / with_headers / with_body / with_text / with_json
    .use_response(response => response)
    // 响应侧的错误路径:非 2xx、超时、断连都会走到这里——统一错误处理与重试写在这
    .use_response(response => response, on_rejected=error => plain.request(error.config())),
    )

    • 顺序:请求侧后注册先跑(LIFO)、响应侧先注册先跑(FIFO),两个方向相反。
    • 拦截器拿到的配置是合并后的;client.create(...) 派生的实例会继承这份链。
    • 一次请求只跑一遍:跟 5 跳重定向也只跑一次。响应侧只作用于 Client::request,两个流式入口不过响应链。
    • 闭包要写箭头形式(config => ...)或显式标 async fn——效果推断只认箭头语法,具名同步函数传不进去。

    #取消请求

    一个 CancelToken 可以传给任意多次请求,从任何地方喊停(另一条协程、进度回调、看门狗):

    let stop = @moonhttp.CancelToken::new()

    @async.with_task_group(group => {
    let running = group.spawn(() => {
    api.request(@moonhttp.Config::new("/reports/big.csv").with_cancel_token(stop)) catch {
    error if error.is_cancelled() => println("已取消:" + error.message())
    }
    })
    @async.sleep(2_000)
    stop.cancel(message="Operation canceled by the user.")
    running.wait()
    })

    取消能打断挂起中的连接动作(等首字节、建连、传大 body、读响应体),流式入口在消费过程中取消也生效(下一次读取抛 Cancelled,而不是退化成流结束)。token 是一次性的,cancel 给的 message 就是错误文案;取消发生在响应头到手之后时,错误里带着已经收到的部分响应。

    #自动重定向

    max_redirects 默认 5 跳,设成 0 就是不跟随(3xx 原样交给状态码校验),三个入口都跟。跨 host 跟随时会丢掉 Authorization / Cookie 这类凭据;跟到超限抛 TooManyRedirects,错误里带着最后那个 3xx 响应。

    #代理

    let client = @moonhttp.create(
    @moonhttp.Config::default()
    .with_base_url("https://api.example.com")
    .with_proxy("127.0.0.1", port=9000, username="mikeymike", password="rapunz3l"),
    )

    http 与 https 目标都经 CONNECT 隧道转发(代理服务器得支持 CONNECT)。username / password 只落在建隧道的那个请求上,不会发给目标服务器。只支持 http / https 代理,不读 http_proxy 之类的环境变量;配了代理却没给 host 会直接报错,不会悄悄直连。

    #上传与下载进度

    client.request(
    @moonhttp.Config::new("/upload")
    .with_method(@moonhttp.Method::Post)
    .with_data_from_json({ "name": "moon" })
    .with_on_upload_progress(fn(event) { println("已上传 \{event.loaded} 字节") })
    .with_on_download_progress(fn(event) { println("已下载 \{event.loaded} 字节") }),
    )

    ProgressEvent 只有 loaded(已传输字节)与 total(总字节,None 表示长度未知,chunked 响应与压缩响应都可能不准),外加算比例的 progress();方向由哪个回调被调用表达。下载进度由「库读全量」的两条路(request 与 read_all)触发,自己按块读时自行累加。回调是同步执行且不允许抛错的,别在里面做耗时的事。

    #流式响应与 SSE

    request 会把响应体读全,SSE 这类一直不结束的响应要用另外两个入口。

    ///|
    async fn download(api : @moonhttp.Client) -> Unit raise @moonhttp.HttpError {
    let res = api.stream(@moonhttp.Config::new("/big-file"))
    println(res.status) // 响应头已到手,body 还没读
    while res.read_some() is Some(chunk) {
    println(chunk.length())
    }
    }

    ///|
    async fn watch(api : @moonhttp.Client) -> Unit raise @moonhttp.HttpError {
    let events = api.sse(@moonhttp.Config::new("/events"))
    while events.next_event() is Some(event) {
    println(event.event + ": " + event.data) // message: {...}
    }
    }

    StreamResponse 还有 read_all() 与 read_until(分隔符);SseEvent 带 event(缺省 "message")、data、id 与 retry(后两者是持久状态,断线重连要用)。读到 EOF 会自动关连接,中途结束时记得自己 close()——本项目没有连接复用,忘记关就漏一条连接。Client::sse 要求响应头声明 text/event-stream,拿到的不是事件流会报 NotSupported;服务端不声明却是 SSE 的场合,用 Client::stream 配公开的 SseParser 自己驱动。

    #错误处理

    失败抛 HttpError,它带着错误分类、出错时的配置,以及已经收到的响应(None 表示连响应头都没收到):

    try {
    ignore(api.request(config))
    } catch {
    @moonhttp.HttpError(info) => {
    println(info.code) // BadRequest
    println(info.message) // 请求失败,状态码 404
    println(info.response.unwrap().status) // 404
    println(info.config.url) // 出错时的配置,便于定位
    }
    }

    ErrorCode触发时机
    BadRequest状态码 4xx 且未通过校验
    BadResponse状态码 5xx(或其它非 2xx)
    Network连接 / DNS / TLS 失败、读响应体中途断连、代理拒绝建隧道
    Timeout超过 timeout
    Cancelled被 CancelToken 取消(error.is_cancelled())
    InvalidUrl既没有 url 也没有可用的 base_url
    NotSupported传输层无法完成该请求(例如重定向到非 http(s) 协议)
    TooManyRedirects重定向次数超过 max_redirects

    #用 Mock 传输层测试

    传输层是可替换的 Transport trait,测试时换掉真实网络(记得在自己的 moon.pkg 里加上 "q2316367743/moonhttp/transport"):

    let mock = @transport.MockTransport::new(response)
    let transport : &@transport.Transport = mock
    ignore(@moonhttp.Client::new(transport=transport).request(@moonhttp.Config::new("/users")))
    println(mock.last_request().unwrap().url) // 已经拼好 base_url 与 query 的完整地址

    MockTransport 会记下收到的每个请求,也能预置一串响应或固定失败(from_responses / failing);自定义传输只需实现一个 send 方法。

    #暂不支持与后续计划

    以下能力本版没有实现,配置里也不会出现对应字段(避免「配置了但完全不生效」):

    • 快捷方法 get / post / put / delete / head / options / patch:都是 request 的薄封装,计划中。
    • 请求体流式上传:请求体目前是一次性字节,表单含文件时整块驻留内存;计划下一期做可写流,让调用方一段段喂数据。
    • 连接复用:每次请求新建连接,计划做连接池以省掉重复握手。
    • cookie:不管理 cookie(没有 withCredentials / xsrf*,响应里的 Set-Cookie 也读不到);计划做跨请求复用。
    • SSE 自动重连:id / retry 已经作为持久状态带出来了,按 retry 间隔重订阅留给上层;计划内置。
    • 响应体自动解析:读法由你在 text() / bytes() / json() 里显式选;静态类型下「猜内容类型」需要先重新设计响应类型的形态。
    • transformRequest / transformResponse:不做成独立配置项——请求体固定为四种形态,响应体读法在 Response 上,要「发请求前换 body」「拿到响应后改正文」就在两段拦截器里做。
    • 拦截器的 eject / clear / runWhen:撤下一个拦截器就重新构建一份 Interceptors 值。
    • 重定向的 beforeRedirect 回调与自定义敏感头名单。
    • 代理的 SOCKS 支持、http_proxy / no_proxy 环境变量,以及按请求关掉代理的开关(显式 with_proxy 已支持)。
    • 单条头的多值:一个头名只能对应一个字符串值。

    #参与开发

    代码在 src/ 下(七个功能包 + 一组可运行示例),实现思路、契约与改动清单在 docs/。

    moon check # 类型检查 moon test # 全部测试(不需要外网) moon run src/main # 快速上手示例(真实网络,本地手动测试用,不随包发布) moon run src/main/proxy # 其余七个方向同理:把 <方向> 换成 basics/methods/proxy/ # progress/redirect/interceptors/sse 之一 moon info && moon fmt # 更新 .mbti 接口并格式化,提交前跑一次

    示例的索引(每个演示什么、前置条件是什么)在 src/main/README.md, 维护者视角的取舍与「加一个新示例要做什么」在 docs/13。

    提交前建议把钩子装上,每次 commit 会自动跑一遍 moon check:

    chmod +x .githooks/pre-commit && git config core.hooksPath .githooks

    CI 会跑 moon check --deny-warn、moon build、moon test,并要求 .mbti 接口文件与 moon fmt 的结果没有 diff。动手改代码前先看 docs/README.md 末尾的「改动时的同步清单」。

    #许可证

    Apache-2.0,见 LICENSE。

    Auth

    门面:把子包里对使用者有意义的类型从根包再导出一份。

    这样开发者只 import 一个 @moonhttp 就能拿到全部公开类型, 不必记住每个类型归属哪个子包。子包结构本身仍然保留, 需要精细控制的使用者(例如自己实现 Transport)可以直接依赖对应子包。

    约定:凡是出现在公开签名里的类型,都在定义它的包里再导出一次, 因此各层之间不会出现「要用这个 API 却不知道类型该从哪 import」的情况。 请求体的相关类型也一并再导出:with_data_from_form 的入参是 FormData, serialize_body 的返回值是 SerializedBody,用它们不该再 import 一个 @config。 进度回调的两个类型同理:with_on_upload_progress 的入参是 ProgressCallback。 取消句柄也一样:with_cancel_token 的入参是 CancelToken,而取消之后 调用方还要拿它查状态(is_cancelled / reason)。

    CancelToken

    门面:把子包里对使用者有意义的类型从根包再导出一份。

    这样开发者只 import 一个 @moonhttp 就能拿到全部公开类型, 不必记住每个类型归属哪个子包。子包结构本身仍然保留, 需要精细控制的使用者(例如自己实现 Transport)可以直接依赖对应子包。

    约定:凡是出现在公开签名里的类型,都在定义它的包里再导出一次, 因此各层之间不会出现「要用这个 API 却不知道类型该从哪 import」的情况。 请求体的相关类型也一并再导出:with_data_from_form 的入参是 FormData, serialize_body 的返回值是 SerializedBody,用它们不该再 import 一个 @config。 进度回调的两个类型同理:with_on_upload_progress 的入参是 ProgressCallback。 取消句柄也一样:with_cancel_token 的入参是 CancelToken,而取消之后 调用方还要拿它查状态(is_cancelled / reason)。

    Config

    门面:把子包里对使用者有意义的类型从根包再导出一份。

    这样开发者只 import 一个 @moonhttp 就能拿到全部公开类型, 不必记住每个类型归属哪个子包。子包结构本身仍然保留, 需要精细控制的使用者(例如自己实现 Transport)可以直接依赖对应子包。

    约定:凡是出现在公开签名里的类型,都在定义它的包里再导出一次, 因此各层之间不会出现「要用这个 API 却不知道类型该从哪 import」的情况。 请求体的相关类型也一并再导出:with_data_from_form 的入参是 FormData, serialize_body 的返回值是 SerializedBody,用它们不该再 import 一个 @config。 进度回调的两个类型同理:with_on_upload_progress 的入参是 ProgressCallback。 取消句柄也一样:with_cancel_token 的入参是 CancelToken,而取消之后 调用方还要拿它查状态(is_cancelled / reason)。

    FormData

    门面:把子包里对使用者有意义的类型从根包再导出一份。

    这样开发者只 import 一个 @moonhttp 就能拿到全部公开类型, 不必记住每个类型归属哪个子包。子包结构本身仍然保留, 需要精细控制的使用者(例如自己实现 Transport)可以直接依赖对应子包。

    约定:凡是出现在公开签名里的类型,都在定义它的包里再导出一次, 因此各层之间不会出现「要用这个 API 却不知道类型该从哪 import」的情况。 请求体的相关类型也一并再导出:with_data_from_form 的入参是 FormData, serialize_body 的返回值是 SerializedBody,用它们不该再 import 一个 @config。 进度回调的两个类型同理:with_on_upload_progress 的入参是 ProgressCallback。 取消句柄也一样:with_cancel_token 的入参是 CancelToken,而取消之后 调用方还要拿它查状态(is_cancelled / reason)。

    FormFile

    门面:把子包里对使用者有意义的类型从根包再导出一份。

    这样开发者只 import 一个 @moonhttp 就能拿到全部公开类型, 不必记住每个类型归属哪个子包。子包结构本身仍然保留, 需要精细控制的使用者(例如自己实现 Transport)可以直接依赖对应子包。

    约定:凡是出现在公开签名里的类型,都在定义它的包里再导出一次, 因此各层之间不会出现「要用这个 API 却不知道类型该从哪 import」的情况。 请求体的相关类型也一并再导出:with_data_from_form 的入参是 FormData, serialize_body 的返回值是 SerializedBody,用它们不该再 import 一个 @config。 进度回调的两个类型同理:with_on_upload_progress 的入参是 ProgressCallback。 取消句柄也一样:with_cancel_token 的入参是 CancelToken,而取消之后 调用方还要拿它查状态(is_cancelled / reason)。

    FormValue

    门面:把子包里对使用者有意义的类型从根包再导出一份。

    这样开发者只 import 一个 @moonhttp 就能拿到全部公开类型, 不必记住每个类型归属哪个子包。子包结构本身仍然保留, 需要精细控制的使用者(例如自己实现 Transport)可以直接依赖对应子包。

    约定:凡是出现在公开签名里的类型,都在定义它的包里再导出一次, 因此各层之间不会出现「要用这个 API 却不知道类型该从哪 import」的情况。 请求体的相关类型也一并再导出:with_data_from_form 的入参是 FormData, serialize_body 的返回值是 SerializedBody,用它们不该再 import 一个 @config。 进度回调的两个类型同理:with_on_upload_progress 的入参是 ProgressCallback。 取消句柄也一样:with_cancel_token 的入参是 CancelToken,而取消之后 调用方还要拿它查状态(is_cancelled / reason)。

    Headers

    大小写不敏感的 HTTP 头集合,对应 axios 的 AxiosHeaders。

    内部以小写化后的头名作为 Map 的 key,值里再记住写入时的原始拼写, 于是 get("Content-Type") 与 get("content-type") 命中同一条记录, 而遍历/打印时仍然保留调用方书写时的形状(与 axios「首次出现的拼写胜出」一致)。

    与 axios 相比的两处有意简化(README 里有完整清单):
    • 不支持 axios 用 false 表示「这条头禁止被默认值覆盖」的哨兵值;
    • 同一个头名只能有一个字符串值,不展开多值头(axios 允许数组)。

    所有变更方法都返回新实例而不是就地修改:Config 是值语义的, 合并配置时同一份 Headers 可能被多个 Config 引用, 返回新值可以避免「改一个实例的头,另一个实例跟着变」这类共享可变状态 bug。

    Method

    门面:把子包里对使用者有意义的类型从根包再导出一份。

    这样开发者只 import 一个 @moonhttp 就能拿到全部公开类型, 不必记住每个类型归属哪个子包。子包结构本身仍然保留, 需要精细控制的使用者(例如自己实现 Transport)可以直接依赖对应子包。

    约定:凡是出现在公开签名里的类型,都在定义它的包里再导出一次, 因此各层之间不会出现「要用这个 API 却不知道类型该从哪 import」的情况。 请求体的相关类型也一并再导出:with_data_from_form 的入参是 FormData, serialize_body 的返回值是 SerializedBody,用它们不该再 import 一个 @config。 进度回调的两个类型同理:with_on_upload_progress 的入参是 ProgressCallback。 取消句柄也一样:with_cancel_token 的入参是 CancelToken,而取消之后 调用方还要拿它查状态(is_cancelled / reason)。

    ProgressCallback

    进度回调。

    noraise:回调是在请求发送 / 响应读取的中途被调用的,它抛出的错误没有 合理的归属方(既不属于这次请求的传输错误,也不该让整条请求失败), 所以类型上就要求它不抛。想在回调里记日志、推 UI 状态都没问题。

    回调是同步执行的:它占用请求本身的时间预算(上传回调落在 timeout 覆盖的发送阶段里),所以别在里面做耗时的事。

    ProgressEvent

    门面:把子包里对使用者有意义的类型从根包再导出一份。

    这样开发者只 import 一个 @moonhttp 就能拿到全部公开类型, 不必记住每个类型归属哪个子包。子包结构本身仍然保留, 需要精细控制的使用者(例如自己实现 Transport)可以直接依赖对应子包。

    约定:凡是出现在公开签名里的类型,都在定义它的包里再导出一次, 因此各层之间不会出现「要用这个 API 却不知道类型该从哪 import」的情况。 请求体的相关类型也一并再导出:with_data_from_form 的入参是 FormData, serialize_body 的返回值是 SerializedBody,用它们不该再 import 一个 @config。 进度回调的两个类型同理:with_on_upload_progress 的入参是 ProgressCallback。 取消句柄也一样:with_cancel_token 的入参是 CancelToken,而取消之后 调用方还要拿它查状态(is_cancelled / reason)。

    Proxy

    门面:把子包里对使用者有意义的类型从根包再导出一份。

    这样开发者只 import 一个 @moonhttp 就能拿到全部公开类型, 不必记住每个类型归属哪个子包。子包结构本身仍然保留, 需要精细控制的使用者(例如自己实现 Transport)可以直接依赖对应子包。

    约定:凡是出现在公开签名里的类型,都在定义它的包里再导出一次, 因此各层之间不会出现「要用这个 API 却不知道类型该从哪 import」的情况。 请求体的相关类型也一并再导出:with_data_from_form 的入参是 FormData, serialize_body 的返回值是 SerializedBody,用它们不该再 import 一个 @config。 进度回调的两个类型同理:with_on_upload_progress 的入参是 ProgressCallback。 取消句柄也一样:with_cancel_token 的入参是 CancelToken,而取消之后 调用方还要拿它查状态(is_cancelled / reason)。

    ProxyProtocol

    门面:把子包里对使用者有意义的类型从根包再导出一份。

    这样开发者只 import 一个 @moonhttp 就能拿到全部公开类型, 不必记住每个类型归属哪个子包。子包结构本身仍然保留, 需要精细控制的使用者(例如自己实现 Transport)可以直接依赖对应子包。

    约定:凡是出现在公开签名里的类型,都在定义它的包里再导出一次, 因此各层之间不会出现「要用这个 API 却不知道类型该从哪 import」的情况。 请求体的相关类型也一并再导出:with_data_from_form 的入参是 FormData, serialize_body 的返回值是 SerializedBody,用它们不该再 import 一个 @config。 进度回调的两个类型同理:with_on_upload_progress 的入参是 ProgressCallback。 取消句柄也一样:with_cancel_token 的入参是 CancelToken,而取消之后 调用方还要拿它查状态(is_cancelled / reason)。

    RequestInterceptor

    请求拦截器:拿到合并后的配置,返回(可能改写过的)配置。

    拿到的已经是「实例默认值 + 本次请求配置」的合并结果(与 axios 一致),所以 可以直接改地址、加头、换请求体,甚至换方法。抛错表示拦下这次请求:请求 不会发出,错误按 axios 的 promise 链语义交给响应侧的错误处理器 (HttpError::new 就是给这种中止准备的,见 http_error.mbt)。

    类型带 async 与 raise HttpError 不是装饰:raise 让拦截器能中止请求, async 让「发请求前先去别处取一个 token」这类事写在同一处。代价是具名同步 函数不能直接传进来——MoonBit 的效果推断只认箭头语法,写成 config => ... 或用 async fn 显式标注即可。

    ResponseEncoding

    门面:把子包里对使用者有意义的类型从根包再导出一份。

    这样开发者只 import 一个 @moonhttp 就能拿到全部公开类型, 不必记住每个类型归属哪个子包。子包结构本身仍然保留, 需要精细控制的使用者(例如自己实现 Transport)可以直接依赖对应子包。

    约定:凡是出现在公开签名里的类型,都在定义它的包里再导出一次, 因此各层之间不会出现「要用这个 API 却不知道类型该从哪 import」的情况。 请求体的相关类型也一并再导出:with_data_from_form 的入参是 FormData, serialize_body 的返回值是 SerializedBody,用它们不该再 import 一个 @config。 进度回调的两个类型同理:with_on_upload_progress 的入参是 ProgressCallback。 取消句柄也一样:with_cancel_token 的入参是 CancelToken,而取消之后 调用方还要拿它查状态(is_cancelled / reason)。

    ResponseErrorHandler

    type ResponseErrorHandler = async (HttpError) -> Response raise HttpError

    响应拦截器的错误路径,对应 axios 里 use(onFulfilled, onRejected) 的第二个 参数。三类错误都会走到这里:非 2xx(HttpError::response() 上有完整响应, 状态码、响应头、错误正文都在)、传输失败(超时、断连,response() 可能是 None 或只有半截正文)、以及被请求拦截器拦下的请求。

    返回一个 Response 表示这个错误已经处理掉,它会作为 request 的结果 返回;raise error 表示不处理、继续往外传——与 axios 的 Promise.reject(error) 等价。重试就是在这里再发一次请求。

    ResponseInterceptor

    type ResponseInterceptor = async (Response) -> Response raise HttpError

    响应拦截器(正常路径):拿到最终的 Response,返回(可能改写过的)响应。

    只在 Client::request 上跑:stream / sse 的「响应」是还没读的字节流, 改写与重试都没有明确语义(读法由入口决定是本项目已有的口径)。改写响应用 Response::with_status / with_headers / with_body / with_text / with_json; 「解析 → 处理 → 写回」的配方(json() 抛 @json.ParseError,要用 try ... catch 收掉)见 docs/11-interceptors.md。

    SerializedBody

    门面:把子包里对使用者有意义的类型从根包再导出一份。

    这样开发者只 import 一个 @moonhttp 就能拿到全部公开类型, 不必记住每个类型归属哪个子包。子包结构本身仍然保留, 需要精细控制的使用者(例如自己实现 Transport)可以直接依赖对应子包。

    约定:凡是出现在公开签名里的类型,都在定义它的包里再导出一次, 因此各层之间不会出现「要用这个 API 却不知道类型该从哪 import」的情况。 请求体的相关类型也一并再导出:with_data_from_form 的入参是 FormData, serialize_body 的返回值是 SerializedBody,用它们不该再 import 一个 @config。 进度回调的两个类型同理:with_on_upload_progress 的入参是 ProgressCallback。 取消句柄也一样:with_cancel_token 的入参是 CancelToken,而取消之后 调用方还要拿它查状态(is_cancelled / reason)。

    SseEvent

    SSE 解析器也再导出:它是独立的纯逻辑包,既能被 Client::sse 驱动, 也能用在别的字节来源上(文件、WebSocket),是「服务端不声明 text/event-stream 时自己接」的公开逃生口。

    SseParser

    SSE 解析器也再导出:它是独立的纯逻辑包,既能被 Client::sse 驱动, 也能用在别的字节来源上(文件、WebSocket),是「服务端不声明 text/event-stream 时自己接」的公开逃生口。

    Transport

    传输层抽象也一并再导出:自定义传输实现是公开的扩展点。

    HttpError

    pub(all) suberror HttpError {
    HttpError(ErrorInfo)
    }

    本模块对外的错误类型。

    用 pub(all) suberror 而不是 pub suberror:后者不导出构造子, 使用方就只能拿到错误、无法按分类匹配。这里刻意把构造子放开, 让调用方能写 catch { @moonhttp.HttpError(info) => ... }。

    构造子必须定义在根包:pub using 能再导出类型,但再导出不了错误构造子, 所以 HttpError 一旦搬进子包,上面那种解构写法就失效了(实测证据见 docs/01-architecture.md)。本文件在根包里拆出来,不影响这一点。
    impl Show for HttpError

    HttpError::code

    fn HttpError::code(self : HttpError) -> ErrorCode

    错误的分类。

    HttpError::config

    触发这次错误的配置(已完成合并)。

    HttpError::info

    fn HttpError::info(self : HttpError) -> ErrorInfo

    取出错误的详细信息。

    HttpError::is_cancelled

    fn HttpError::is_cancelled(self : HttpError) -> Bool

    这次失败是不是被取消(对应 axios 的 axios.isCancel)。

    等价于 error.code() is ErrorCode::Cancelled,存在的意义是让调用点读起来 与 axios 的写法一致,也避免每次手写模式匹配。取消来自调用方自己的 CancelToken(不是超时、不是网络错误),通常该安静地收场而不是报错。

    client.request(config) catch {
    error if error.is_cancelled() => println("已取消:" + error.message())
    error => println(error.to_string())
    }

    HttpError::message

    fn HttpError::message(self : HttpError) -> String

    错误的文字描述。

    HttpError::new

    fn HttpError::new(message : String, code : ErrorCode, config :
    Config
    ) -> HttpError

    直接构造一个不带响应的 HttpError。

    这是 ErrorInfo 的公开入口:包外的代码此前只能读错误、不能造错误,而 请求拦截器需要造——它主动中止请求时就得抛一个错误出去,见 docs/11-interceptors.md:

    .use_request(config => {
    if token == "" {
    raise @moonhttp.HttpError::new("缺少 token", @moonhttp.ErrorCode::BadRequest, config)
    }
    config.with_header("Authorization", token)
    })

    code 由调用方按语义选。客户端侧拒绝请求(缺凭据、参数不合法)建议用 ErrorCode::BadRequest:axios 在同样的位置上用的也是 ERR_BAD_REQUEST (它同时覆盖「4xx 响应」与「请求本身不合法」两种情况)。

    错误里没有响应(response() 返回 None):这次请求根本没发出去。 要把某个响应挂上去,用 ErrorInfo 字面量自己造。

    HttpError::output

    fn HttpError::output(self : HttpError, logger : &Logger) -> Unit

    HttpError::response

    fn HttpError::response(self : HttpError) -> Response?

    错误里带的响应:

    • 状态码没通过校验时是完整响应;
    • 传输层失败(超时、断连)发生在响应头到手之后时,是失败前已经收到的部分 ——响应体字节可能只有半截(bytes() / text() 拿到的就是那半截);
    • 连响应头都没收到时为 None。

    HttpError::to_string

    fn HttpError::to_string(self : HttpError) -> String

    渲染成 错误码: 描述 的形式,便于日志与断言。

    Client

    pub struct Client {
    // private fields
    }

    HTTP 客户端实例,对应 axios 的 AxiosInstance。

    实例是不可变的:create 不修改自身,而是返回一个继承了当前默认值的新实例。 这样多个实例之间不会互相影响,也就不会出现 axios 里 「改了全局 axios.defaults 影响所有实例」那类隐蔽的相互干扰。

    Client::create

    基于当前实例派生一个新实例,对应 axios 的 instance.create(config)。

    新实例的默认值是「本实例的默认值」叠加 config,因此会继承父实例的 base_url、公共头等设置。注意 axios 的 instance.create 合并的是 该实例自己的 defaults、与全局 defaults 无关,这里的行为一致。

    拦截器也继承:派生实例与父实例用同一份链。与 axios 不同(axios.create() 造出来的是没有拦截器的新实例),这里选继承——create 在本项目是「同一个实例 换套默认值」,而换一套默认值就该照样带上认证头、日志这些横切逻辑。 确实要不带拦截器的实例,用 Client::new(config~) 新造一个。

    Client::defaults

    本实例的默认配置。返回的是不可变值,修改它请用 create 派生。

    Client::new

    创建实例。

    • config:叠加在内置默认值之上的用户配置, 对应 axios 里 axios.create(config) 的语义(axios.defaults 的位置由 @config.defaults() 固定提供);
    • transport:传输层实现,缺省是真正走网络的 AsyncHttpTransport; 测试或特殊场景可以注入自己的实现(例如 MockTransport);
    • interceptors:请求 / 响应拦截器,缺省是一条都不挂(Interceptors::new())。

    Client::request

    async fn Client::request(self : Client, config :
    Config
    ) -> Response raise HttpError

    发送一次请求并返回响应;任何失败都抛出 HttpError。

    步骤与 axios 的 _request 一致:
    1. 合并配置(实例默认值在下、本次请求配置在上);
    2. 确定请求方法;
    3. 请求拦截器:后注册先跑,拿到的就是第 2 步的最终配置;
    4. 拼接完整地址并追加 query、拍平头、序列化 body;
    5. 交给传输层发送,需要时自动跟随重定向(最多 max_redirects 跳);
    6. 把响应体读到 EOF(读全量才有完整的响应体);
    7. 校验状态码;
    8. 响应拦截器:先注册先跑,正常路径改响应、错误路径救错或重试。

    两段拦截器与重定向的相对位置是关键:整条重定向链只跑一次拦截器 (axios 的适配器内部跟随重定向,拦截器在它上层,两边一致)。理由与「为什么 不把拦截器做成传输层中间件」见 docs/11-interceptors.md。

    重定向默认跟随:max_redirects 缺省是 5(axios 请求配置文档里的默认值), 3xx 且带 Location 时自动跟过去,下一跳的地址 / 方法 / body / 凭据怎么变 见 @config.Config::next_redirect(规则对齐 follow-redirects)。 跟到超限抛 ErrorCode::TooManyRedirects;把上限设成 0 则完全不跟随, 3xx 原样落到状态码校验手里。

    与 axios 的一处刻意差异:这里不解析 JSON、也不预先解码。响应体以原始 字节交出去,读法由调用方选:Response::text()(按 response_encoding 解码)、 Response::bytes()(精确字节)、Response::json()(解码后 @json.parse)。 「猜内容类型」在静态类型下只会把错误推迟到调用方,而 text/plain 的 123 被解成数字那类静默错误比多写一行 @json.parse 贵得多。

    传输层的错误在这里被翻译成对外的 ErrorCode, 所以调用方不需要认识 moonbitlang/async 的错误类型。

    失败不等于「什么都没收到」:响应头到手之后才失败的请求(读响应体的中途 超时、连接被重置),错误里会带上已经收到的响应——状态行、响应头与 读到的部分正文,从 HttpError::response() 取。连响应头都没到手时, 错误里没有响应。两类失败都会先经过响应拦截器的错误路径(重试的落点)。

    取消走配置里的 cancel_token(Config::with_cancel_token,对应 axios 的 cancelToken):请求进入管线前 token 已经取消时什么都不做,进行中的取消会 打断挂起的连接动作,对外是一个 ERR_CANCELED 错误、可以用 HttpError::is_cancelled() 认出来。设计、覆盖范围与注意事项见 docs/12-cancellation.md。

    不想读全响应体时用另外两个入口:大文件下载用 Client::stream(原始字节流), SSE 用 Client::sse(事件流)。读不读、按什么单位读,由入口决定而不是配置; 它们只过请求拦截器,不过响应拦截器。

    Client::sse

    发起一次 SSE 请求,把解析好的事件流交给调用方。

    与 stream 共用前半段,多一道准入检查:响应头必须声明 Content-Type: text/event-stream。不是就立刻关掉连接并报 ErrorCode::NotSupported——「拿到的不是 SSE 却按 SSE 读」是使用错误, 让它响亮失败,比把 JSON 或二进制解成一堆莫名其妙的事件好得多。 确实要接一个不声明类型的服务端,可以用 Client::stream + 公开的 @moonhttp.SseParser 自己驱动(那正是解析器独立成包的好处)。

    这道检查失败时错误里挂着已经收到的响应(状态行与响应头), 但不读响应体:声明了别的类型就可能是任意大小的二进制,要看原文请改走 Client::stream。

    拿到 SseStream 之后要负责读到 EOF 或调用 close(),否则连接不会释放。

    Client::stream

    发起一次请求,但不读响应体,把原始字节流交给调用方。

    与 request 共用前半段(合并配置 → 定方法 → 拼地址/头/body → 发送 → 校验), 区别是响应体不读:适合大文件下载、需要自己按块处理的场景。 按 SSE 事件读请用 Client::sse——那是另一个协议,两个入口的类型也不同, 免得把二进制数据喂进事件解析器(见 facade.mbt 里 SseStream 的说明)。

    拿到 StreamResponse 之后要负责读到 EOF 或调用 close(), 否则这条连接不会释放。

    ErrorCode

    pub(all) enum ErrorCode {
    BadRequest
    BadResponse
    Network
    Timeout
    InvalidUrl
    NotSupported
    Cancelled
    TooManyRedirects
    } derive(Eq,
    Debug
    )

    错误的分类,对应 axios 的 AxiosError.code。

    分成这几档是为了让调用方能按「该怎么处理」分支: 超时可以重试,网络失败要看连接,4xx 要看自己的请求参数, 5xx 要等服务端恢复。
    impl Show for ErrorCode

    ErrorCode::equal

    fn ErrorCode::equal(ErrorCode, ErrorCode) -> Bool

    ErrorCode::not_equal

    fn ErrorCode::not_equal(x : ErrorCode, y : ErrorCode) -> Bool

    ErrorCode::output

    fn ErrorCode::output(self : ErrorCode, logger : &Logger) -> Unit

    ErrorCode::to_string

    fn ErrorCode::to_string(self : ErrorCode) -> String

    渲染成 axios 风格的错误码字符串(即 AxiosError.code 的取值)。

    超时用的是 ECONNABORTED 而不是 ETIMEDOUT:axios 默认就是前者, 只有在打开 transitional.clarifyTimeoutError 时才会改用后者。

    取消用的是 ERR_CANCELED(axios 的 AxiosError.ERR_CANCELED,也是 它 CanceledError 上的 code);与超时分开,调用方才能把「用户主动中止」 与「超时兜底」区分对待——前者通常不该重试,也不该报给用户。

    重定向上限用 ERR_FR_TOO_MANY_REDIRECTS:这是 axios 透传的 follow-redirects 错误码(axios 自己也定义了 AxiosError.ERR_FR_TOO_MANY_REDIRECTS 这个常量),跟着用同一个字符串,调用方按码分支时不必区分是哪一家的实现。

    ErrorInfo

    pub(all) struct ErrorInfo {
    message : String
    code : ErrorCode
    config :
    Config

    response : Response?
    }

    错误的详细信息,对应 AxiosError 上的 message / code / config / response。

    Interceptors

    pub struct Interceptors {
    // private fields
    }

    拦截器的集合,对应 axios 实例上的 interceptors。

    链式构建、值语义:use_* 不改自己,而是返回追加后的新值(与 FormData::append_text 同一口径——数组是共享的,就地改会让别的集合跟着变)。 传给 Client::new 之后视作冻结。

    let client = @moonhttp.Client::new(
    interceptors=@moonhttp.Interceptors::new()
    .use_request(config => config.with_header("X-Token", token))
    .use_response(response => response.with_text(unwrap(response.text()))),
    )

    Interceptors::new

    空的拦截器集合:一条链都不挂。

    Interceptors::use_request

    追加一个请求拦截器:后注册的先跑(axios 的请求拦截器是 LIFO)。

    Interceptors::use_response

    fn Interceptors::use_response(self : Interceptors, on_fulfilled : async (Response) -> Response raise HttpError, on_rejected? : async (HttpError) -> Response raise HttpError) -> Interceptors

    追加一对响应处理器:先注册的先跑(axios 的响应拦截器是 FIFO)。

    on_rejected 省略表示「这个拦截器不管错误」,错误继续交给更晚注册的错误 处理器(与 axios 里不写第二个参数同义)。

    Response

    pub(all) struct Response {
    status : Int
    status_text : String
    headers :
    Headers

    config :
    Config

    // private fields
    } derive(
    Debug
    )

    对外的响应对象,对应 axios 的 AxiosResponse。

    响应体只保留原始字节(私有字段 raw),怎么读由三个方法决定:
    • bytes():原始字节,二进制内容或「想用别的编码自己解」走这里;
    • text():按 config.response_encoding 解码成文本(lossy);
    • json():先按同一编码解成文本,再交给 @json.parse。

    这是与 axios 的一处刻意差异:axios 的 res.data 是 any,由 responseType / transformResponse / forcedJSONParsing 猜出来;本项目不猜——没有「默认解码」 这一说,读法摆在方法上。猜内容类型在静态类型下只会把错误推迟 (text/plain 的 123 被解成数字那类静默错误),多一次显式调用反而是更便宜的。

    字段私有是为了让「怎么读」只有一个落点:raw 一旦公开,调用方就会各自 按自己的编码解一遍,response_encoding 这个配置项也就名存实亡了。

    Response::bytes

    fn Response::bytes(self : Response) -> Bytes

    原始响应字节,不做任何解码。

    需要精确字节、二进制内容(图片、压缩包),或想按另一种编码自己解时用它; content_length() 数的也是这份字节。文本请用 text()。

    Response::content_length

    fn Response::content_length(self : Response) -> Int

    响应体的字节长度,等价于 bytes().length()。

    Response::is_success

    fn Response::is_success(self : Response) -> Bool

    状态码是否落在 2xx。

    注意这与「请求是否成功」不是一回事:request 只在校验规则放行时才返回响应, 所以能拿到 Response 通常已经表示成功;这个方法是给 自定义了 validate_status(放行了 3xx/4xx)的场景用的。

    Response::json

    把响应体当 JSON 解析:先按 text() 那套规则(config.response_encoding) 解成文本,再交给 @json.parse(Json 是内建类型,不必 import 额外的东西)。

    解析失败抛的是 @json.ParseError(带出错位置),不是 HttpError—— 「HTTP 这一层出没出问题」与「正文是不是合法 JSON」是两类事:状态码不合规 早在 request 里就抛过了,能拿到 Response 说明这一层已经成功; 把解析错误混进 HttpError 只会让两边的分类都变模糊。不想处理解析错误的 调用方用 try! response.json(),与手写 try! @json.parse(response.text()) 等价。

    与 axios 的差别同样在这里:只有显式调用才解析,且不看 Content-Type 猜——123、true、"x" 这些纯文本本身就是合法 JSON 文本,猜错的后果是 静默的(文本被解成数字)。判断「这到底是不是 JSON」交给知道上下文的那一层。

    Response::text

    fn Response::text(self : Response) -> String

    按 config.response_encoding 把响应体解成文本(编码未设置时按 UTF-8, 与 defaults() 里显式设的默认值一致)。

    解码一律 lossy:该编码下非法的字节解成替换字符 U+FFFD,不抛错 ——响应正文由服务端说了算,为了几个坏字节把整个响应判成失败得不偿失。 需要精确字节读 bytes()。

    解码是按需做的:Response 里只有字节、不缓存文本,调几次就解几次。 同一份响应要反复读文本时(例如在循环里),自己解一次存起来更划算。

    Response::to_repr

    Response::with_body

    fn Response::with_body(self : Response, body : Bytes) -> Response

    换一份响应体字节,返回新响应:bytes() / text() / json() / content_length() 读到的都是新字节(解码才看 response_encoding)。

    Response::with_headers

    换一组响应头,返回新响应。整体替换而不是合并——要合并先自己 merge。

    Response::with_json

    fn Response::with_json(self : Response, data : Json) -> Response

    换一份响应体载荷(Json),返回新响应:先 stringify() 成规范 JSON 文本,再按 config.response_encoding 编成字节(与 with_text 同一口径)。

    这是「响应后处理」的写回入口,对应 axios 的 transformResponse 里那句 res.data = ...:解析用 json(),处理完用这个方法写回,调用方再读到的 就是处理过的载荷。JSON 是静态类型下「任意数据」的落点,所以这一条比 with_text 更贴这种用法。

    两个语义要注意:
    • 写回的是载荷,也就是一份 JSON 文档:Json::String("x") 写成 "x" (带引号),不是裸的 x。要写原文用 with_text。
    • stringify() 的输出是紧凑形式(无缩进、无多余空白),所以「只把正文规范化 一遍」也是它的合法用法。

    Response::with_status

    fn Response::with_status(self : Response, status : Int) -> Response

    换一个状态码,返回新响应。

    给响应拦截器用:把「HTTP 上是 200、业务上是失败」的响应改判,或者把服务端 写错的状态码归一。status_text 不跟着变——它只是状态行的原文。

    改状态码不会重新跑状态码校验:validate_status 早在拦截器之前就跑完了, 返回的响应原样交给调用方(要让它变成失败,请在拦截器里抛 HttpError)。

    Response::with_text

    fn Response::with_text(self : Response, body : String) -> Response

    换一份响应体文本,返回新响应。编码按 config.response_encoding(未设置时 按 UTF-8),与 text() 的读方向同一个编码——with_text(response.text()) 因此是恒等的,改写不会把非 UTF-8 的正文写坏(latin1 的 é 写回去还是 0xE9,详细理由见 @util.encode_body)。

    该编码装不下的码元写成一个 ?(不抛错,与解码的 lossy 口径一致)。 要精确字节、或要一份别的编码的正文,用 with_body 自己编。

    SseStream

    pub(all) struct SseStream {
    status : Int
    status_text : String
    headers :
    Headers

    config :
    Config

    // private fields
    } derive(
    Debug
    )

    SSE 事件流:按事件读响应体,与 StreamResponse(原始字节流)分开。

    为什么不给 StreamResponse 加一个 next_event:那个类型也是下载用的 原始流,而二进制数据里 CR / LF 字节很常见,拿它喂事件解析器只会把字节流 解成一堆无意义的东西(运气不好还会撞出几个假事件)。两个协议拆成两个类型, 「按块读」与「按事件读」各自只有一个入口,调错是编译错误。

    拿到流时状态行与响应头就已经可用(SSE 靠这个先看状态码与 Content-Type)。 连接生命周期与 StreamResponse 一致:读到 EOF 自动关闭, 中途停止必须显式 close()。

    SseStream::close

    fn SseStream::close(self : SseStream) -> Unit

    关闭响应体流并释放连接。幂等。

    读了几个事件就不想读了(例如任务结束)时必须显式调用,否则连接会一直挂着。 关闭之后 next_event 一律返回 None——包括那一次读取里已经解析好、 还排在解析器队列里的事件。与 StreamResponse::close() 的语义一致: close 就是「到此为止」,不是「释放连接但还能接着读」。

    SseStream::is_success

    fn SseStream::is_success(self : SseStream) -> Bool

    状态码是否落在 2xx。

    与 Response::is_success 同义,是给自定义了 validate_status (放行了 3xx / 4xx)的场景用的:默认配置下能拿到流就说明已经通过了校验。

    SseStream::next_event

    读下一个事件;流结束返回 None。

    事件边界、字段语义(event: / data: / id: / retry:)、注释行、 多行 data: 合并、三种行尾都由 @sse.SseParser 按 WHATWG 规范处理, 调用方拿到的已经是解析好的事件。

    几条与规范一致的边界:
    • 只发了 id: / retry: 或只有心跳注释的块不产生事件,会继续往下读;
    • 流结束时没等到空行的事件丢弃,不会补发半个事件;
    • close() 之后一律返回 None;
    • SseEvent 上的 id / retry 是解析器的持久状态快照, 断线重连要用它们(本项目不自动重连,重连逻辑写在上层)。

    读取失败(超时、断连)时抛 HttpError:错误码是传输层的分类, 状态码与响应头挂在 HttpError::response() 上。

    StreamResponse

    pub(all) struct StreamResponse {
    status : Int
    status_text : String
    headers :
    Headers

    config :
    Config

    // private fields
    } derive(
    Debug
    )

    流式响应:状态行与响应头已经到手,响应体按需读取。

    这是原始字节流的入口(下载、边到边处理都走它)。按事件读 SSE 是另一种协议,用 Client::sse 拿到的 SseStream——两边类型分开, 「按块读」与「按事件读」各自只有一个入口,调错是编译错误而不是 把二进制数据解成一堆无意义的事件。

    与 Response 的差别都由「响应体还没读」这一条派生出来:
    • 没有 text() / bytes() / json():字节还没读出来,读多少由调用方 经 read_some / read_until / read_all 决定(Response 是已经读完的那份);
    • 响应体可能无限长,Response 那条「读全量再交出去」的路在这里不成立;
    • 读取随时可能失败:统一抛 HttpError,错误里带着已经收到的响应 (状态行与响应头一定在;read_all 读到一半失败时还有已读到的字节), 调用方不必认识传输层错误。

    状态码与响应头这些「不读 body 就能拿到」的信息与 Response 保持一致, 所以拿到流之后可以先按状态码与 Content-Type 决定怎么读。

    StreamResponse::close

    fn StreamResponse::close(self : StreamResponse) -> Unit

    关闭响应体流并释放连接。幂等。

    只读了半截就停止(例如 SSE 收到想结束就断开)时必须显式调用, 否则连接会一直挂着。

    StreamResponse::is_event_stream

    fn StreamResponse::is_event_stream(self : StreamResponse) -> Bool

    Content-Type 是否声明了 SSE(text/event-stream)。

    只做查询:这是给「已经拿到原始流、想知道对面到底是不是 SSE」的调用方自查用的。 真按事件读请改用 Client::sse,它在入口处就用同一个判定做了准入检查。

    StreamResponse::is_success

    fn StreamResponse::is_success(self : StreamResponse) -> Bool

    状态码是否落在 2xx。

    与 Response::is_success 一样,是给自定义了 validate_status (放行了 3xx/4xx)的场景用的:默认配置下能拿到响应就说明已经通过了校验。

    StreamResponse::read_all

    async fn StreamResponse::read_all(self : StreamResponse) -> Bytes raise HttpError

    读到 EOF,返回剩下的全部字节,并关闭流。

    用于「先拿流判断状态码、再一次性取内容」的场景(例如下载)。

    中途失败时抛 HttpError,并且已经读到的字节不会丢:它们与状态行、 响应头一起挂在错误的响应上(HttpError::response() 的 bytes() / text()) ——下载断在 90% 时,这 90% 就是现场。

    配了 on_download_progress 时逐块报告进度(与 Client::request 同一条口径: 凡「库读全量」都报告)。按块读的 read_some / read_until 不报告—— 那条路由调用方自己驱动,进度自己累加即可,见 docs/10-progress.md。

    StreamResponse::read_some

    async fn StreamResponse::read_some(self : StreamResponse, max_len? : Int) -> Bytes? raise HttpError

    读取一段响应体;到 EOF 返回 None,此时连接已经关闭。

    返回的块大小不保证(取决于对端一次发了多少数据),需要按边界切分的 协议请用 read_until,不要自己假设一个块就是一条消息。

    读取失败(超时、断连)时抛 HttpError:错误码是传输层的分类, 状态码与响应头挂在 HttpError::response() 上。

    StreamResponse::read_until

    async fn StreamResponse::read_until(self : StreamResponse, sep : String) -> String? raise HttpError

    读到分隔符 sep 为止,返回 sep 之前的内容(sep 被消费掉但不返回)。

    到 EOF 时把剩余内容当作最后一段返回,再读一次才返回 None。

    不要用它切 SSE 事件:SSE 允许 CRLF / LF / CR 三种行尾, 拿 "\n\n" 当分隔符在 CRLF 流上永远匹配不到(字节里没有连续两个 LF)。 按事件读请改用 Client::sse(拿到的是 SseStream), 它按规范处理三种行尾;这里刻意不提供事件读取的入口。

    create

    创建实例的便捷入口,对应 axios.create(config)。

    config 是位置参数(与 axios 的 axios.create(config) 一致), 会叠加在内置默认值之上。不需要任何定制时用 default_client(); 需要注入传输层(例如测试里换成 MockTransport)时用 Client::new(config~, transport~)。

    default_client

    fn default_client() -> Client

    用内置默认值 + 真实传输层创建一个默认实例。

    与 axios 的全局 axios 不同,本模块没有可变的全局状态, 所以这里是一个工厂函数而不是一个共享单例: 想定制默认值请用 create(...) 创建,或从已有实例用 client.create(...) 派生。