Sign in

    #client —— 编排层

    Client 实例、三个请求入口、七个动词快捷方法、两段拦截器、自动重定向、流式响应与所有「构造 HttpError」的动作都在这里。它是唯一会抛 HttpError 的包:response(响应值)与 http_error(错误契约)在它的下游,纯值不抛错、纯契约不造错。

    import { "q2316367743/moonhttp/client", }

    通常不必直接 import 本包:根包 @moonhttp 已把 Client / Interceptors / StreamResponse / SseStream 与工厂函数 create / default_client 再导出。

    #Client

    入口说明
    request(config, signal?)发送请求并读全响应体;两段拦截器都跑,失败按来源分流到两条错误链
    stream(config, signal?)不读响应体,交还原始字节流(下载);只过请求拦截器
    sse(config, signal?)按事件读的 SSE 流,要求响应头声明 text/event-stream;只过请求拦截器
    get / post / put / delete / patch / head / options七个动词快捷方法:补 url 与动词后转调 request
    create(config)派生新实例:叠加默认值,继承拦截器与 cookie 罐
    new(config?, transport?, interceptors?, cookie_jar?)完整构造:可注入传输层 / 拦截器 / cookie 罐

    #StreamResponse / SseStream

    StreamResponse 是原始字节流:read_some()(按块)、read_until(sep)(读到分隔符)、read_all()(读到 EOF,失败时已读字节挂在错误响应上)、close()。SseStream 是事件流:next_event() 返回解析好的 @sse.SseEvent?。两者读失败都抛 HttpError,错误里带着已收到的状态行与响应头。中途停止必须 close(),否则漏连接。

    #拦截器

    ///|
    let api = @moonhttp.Client::new(
    interceptors=@moonhttp.Interceptors::new()
    .use_request(config => config.with_header("X-Token", token))
    .use_response(resp => resp.with_status(200)),
    )

    请求侧后注册先跑(LIFO)、响应侧先注册先跑(FIFO),对齐 axios;与 axios 的有意差异是错误按来源分流——请求阶段的失败只走请求侧错误处理器,响应侧错误处理器只接状态码校验失败。链是值语义:use_* / remove_*(按名撤下)都返回新集合。

    管线、重定向与错误分流的完整规则见 docs/03-request-pipeline.md 与 docs/11-interceptors.md。

    RequestErrorHandler

    请求侧的错误处理器,对应 axios 里 interceptors.request.use(f, r) 的第二个 参数(但覆盖面不同:axios 的请求侧 r 接不到派发失败,这里接得到)。

    拿到的是请求阶段的失败:被请求拦截器拦下、配置拼不出可发送的请求(缺 url、 代理缺 host)、连不上、超时、被取消、重定向超限、读响应体的中途失败。 返回一个 Response 表示这次失败就地救回来,它会作为 request 的结果返回; raise error 表示不处理、交给更早注册的错误处理器(请求侧错误链是后注册 先跑),都没有接住就抛给调用方——与 axios 的 Promise.reject(error) 等价。

    救回的响应不再经过响应侧链:响应侧管的是「服务端这次返回的响应」,而救回的 多半是缓存里存的、或本地兜底合成的,两段链各管一段。要让它也走一遍响应改写, 在处理器里自己调一次改写函数即可。

    重试写在处理器体内:拿 error.config() 用另一个不带这层拦截器的实例再发一次 (与响应侧那份配方一样),见 docs/11-interceptors.md。

    签名与 ResponseErrorHandler 完全相同,分开命名是为了让两段链各自自解释、 也让 .mbti 一眼看出这个处理器属于哪一侧。

    RequestInterceptor

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

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

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

    ResponseErrorHandler

    响应侧的错误处理器,对应 axios 里 interceptors.response.use(f, r) 的第二个参数。

    只接「请求已经成功、但状态码没通过校验」(默认只放行 2xx):这时 HttpError::response() 上有完整响应,状态码、响应头、错误正文都在手里,可以就地 降级成正常响应、改写成统一形状,或重发一次。请求阶段的失败(连不上、超时、 被取消、被请求拦截器拦下)不进这里,它们由 RequestErrorHandler 接 ——这条分流是本项目与 axios 的 promise 链有意不同的地方,理由见 docs/11-interceptors.md。

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

    ResponseInterceptor

    响应拦截器(正常路径):拿到最终的 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。

    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 无关,这里的行为一致。

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

    Client::defaults

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

    Client::delete

    一次 DELETE 请求。有些服务端要求 DELETE 也带请求体,同样由 config.with_data_from_* 给出。

    Client::get

    一次 GET 请求。契约见文件头:url 赢过 config.url,动词赢过 config.http_method 与实例默认方法,其余(实例默认值、拦截器、重定向、 进度、取消信号 signal、状态码校验)与 client.request(...) 完全一致。

    Client::head

    一次 HEAD 请求。响应没有正文,Response::text() 读到的是空串; 想拿响应头之外的元信息(如 Content-Length)用它最省。

    Client::new

    创建实例。

    • config:叠加在内置默认值之上的用户配置, 对应 axios 里 axios.create(config) 的语义(axios.defaults 的位置由 @config.defaults() 固定提供);
    • transport:传输层实现,缺省是真正走网络的自研栈 HttpConnTransport (HTTP/1.0/1.1,RFC 净室实现,见 docs/17-http-protocol.md / docs/18); 测试或特殊场景可以注入自己的实现(例如 MockTransport);
    • interceptors:请求 / 响应拦截器,缺省是一条都不挂(Interceptors::new());
    • cookie_jar:cookie 存储罐,缺省不挂(不做 cookie 维护)。传入后 请求按 URL 自动携带、响应自动存取、过期自动失效(docs/19-cookies.md)。

    Client::options

    一次 OPTIONS 请求(询问服务端支持哪些方法 / CORS 预检那类用途)。

    Client::patch

    一次 PATCH 请求。请求体的给法与 post 相同(走 config.with_data_from_*)。

    Client::post

    一次 POST 请求。请求体由 config 给出,例如 Config::default().with_data_from_json(...) 或 with_data_from_form(...); 快捷方法不接请求体参数(四种请求体形态一视同仁)。

    Client::put

    一次 PUT 请求。请求体的给法与 post 相同(走 config.with_data_from_*)。

    Client::request

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

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

    失败按来源分流到两条错误链(这是与 axios 的 promise 链有意不同的一处, 理由见 docs/11-interceptors.md):
    • 第 1~6 步的失败——被请求拦截器拦下、配置拼不出请求、连不上、超时、被取消、 重定向超限、读响应体失败——交给请求侧错误处理器,它可以返回一个响应把 这次失败救回来;
    • 第 7 步的失败(状态码没通过校验)交给响应侧错误处理器,错误里带着完整 响应,重试、降级、改写成统一形状都写在那里。

    拦截器与重定向的相对位置是关键:整条重定向链只跑一次拦截器 (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() 取。连响应头都没到手时, 错误里没有响应。两类失败都会先经过请求侧的错误处理器(重试的落点)。

    取消信号由 signal? 参数给(对应 axios 的 signal),不属于配置: 请求进入管线前信号已经取消时什么都不做,进行中的取消会打断挂起的连接动作, 对外是一个 ERR_CANCELED 错误、可以用 HttpError::is_cancelled() 认出来。 信号是一次性的,只能按请求传——放进实例默认值会拖垮这个实例之后的所有请求, 所以 Config 里没有这个字段(设计理由见 docs/12-cancellation.md)。

    不想读全响应体时用另外两个入口:大文件下载用 Client::stream(原始字节流), SSE 用 Client::sse(事件流)。读不读、按什么单位读,由入口决定而不是配置; 它们只过请求拦截器:响应侧链与请求侧错误处理器都不跑——两者要交出去的 都是一份 Response,而这两个入口手里是还没读的字节流,救回没有落点。

    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——那是另一个协议,两个入口的类型也不同, 免得把二进制数据喂进事件解析器(见 sse_stream.mbt 里 SseStream 的说明)。

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

    Interceptors

    pub struct Interceptors {
    // private fields
    }

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

    链式构建、值语义:use_* 与 remove_* 都不改自己,而是返回改动后的新值 (与 FormData::append_text 同一口径——数组是共享的,就地改会让别的集合跟着变)。 传给 Client::new 之后视作冻结:实例拿的是当时那份值,之后的注册与移除都不会 影响到它——所以要给某个实例换链,就是换一份值重建(没有实例级的读写口)。

    注册时可以给一条起名(use_request(f, name="auth")):名字让这条可寻址 ——同名再注册替换原位、remove_request(name) 按名撤下,用途是「一份共享的基础链 按客户端裁剪」,这是 Client::create 整份继承拦截器时唯一能表达「少挂一条」的 手段。无名注册的撤不下来,撤链要走名字。

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

    // 共享一份基础链,按客户端裁剪:不必把链再抄一遍,基础链演进时也不会漂

    let base = @moonhttp.Interceptors::new()
    .use_request(config => config.with_header("X-Token", token), name="auth")
    .use_response(rewrite_json, name="rewrite")

    let trimmed = base.remove_response("rewrite").unwrap()

    Interceptors::new

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

    Interceptors::remove_request

    fn Interceptors::remove_request(self : Interceptors, name : StringView) -> Interceptors?

    按名撤下一个请求拦截器,返回新的集合;这个名字没注册过时返回 None。

    名字没对上就返回 None,不 panic,也不静默当成撤成功:拼错的名字必须立刻可见, 否则「拦截器其实还在跑」是查不出来的那类 bug。None 时一个元素都不动, 原集合可以照用。

    对应 axios 的 eject(id),差别在寻址方式:axios 用 use 返回的数字 id,这里用注册 时给的名字——下标在条件注册下会漂,名字不会。无名注册的那几条撤不下来,要撤就得在 注册时起名;撤下只影响这份值,已经交给 Client::new 的实例不受影响。

    Interceptors::remove_response

    fn Interceptors::remove_response(self : Interceptors, name : StringView) -> Interceptors?

    按名撤下一个响应拦截器,返回新的集合;这个名字没注册过时返回 None。 语义与 remove_request 相同,只是落在响应侧(FIFO 顺序里剩下的相对位置不变)。

    Interceptors::use_request

    注册一对请求处理器:正常处理器后注册先跑(axios 的请求拦截器是 LIFO), 错误处理器与它同向(请求侧的两条链只有一个方向,见 docs/11-interceptors.md)。

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

    name 省略 = 无名注册:链尾追加,事后撤不下来(要能撤就得现在起名)。给了名字 则这条可寻址:同名再注册替换原位——位置不变,LIFO 顺序照旧,所以同一侧 内不存在同名两条;remove_request(name) 按名撤下。名字只在请求侧内唯一,与响应侧 互不相干。

    两个可选参数调用时都得写标签(可选参数不能按位置传,实测): .use_request(f, on_rejected=r) / .use_request(f, name="auth")。

    Interceptors::use_response

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

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

    name 与请求侧同规则:省略 = 无名(撤不下来,FIFO 顺序里照常跑),给了名字则 同名替换原位、可用 remove_response(name) 撤下;名字只在响应侧内唯一。

    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

    读到 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(...) 派生。