Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions .trellis/spec/app/errors/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ throw RateLimited(
```

- `report` 的 `message` 寫失敗的動作(英文),會變的值不拼進去;`tag` 是模組或音源 id。
層級由 `expected` 決定,不自己選。
層級由 `expected` 決定,不自己選;`level` 參數只給未捕捉錯誤的處理器用。
- 背景工作也 `report`,但不跳 toast,只更新對應畫面的狀態(ADR 0013 §決定 5)。
- 不要把 `AppError` 直接傳給 `log.error(error: ...)`:它的 `toString()` 沒有原始 error,
那筆 log 就少了原因。
Expand Down Expand Up @@ -87,7 +87,10 @@ extension 的成員(含靜態)與頂層宣告。加欄位、方法或頂層
並寫出為什麼不是給使用者看的文字。原始 error 這類只給 log 的東西用私有欄位,在
`report_error.dart` 讀。

## 重試(網路層,PR 8 起)
## 重試(網路層)

實作在 `SourceHttpClient._sendWithRetry`(`.trellis/spec/app/network/index.md`),
形狀如下:

```dart
for (var attempt = 0; ; attempt++) {
Expand Down
109 changes: 109 additions & 0 deletions .trellis/spec/app/network/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# 網路(`app/lib/core/network/`)

發 HTTP 請求、改攔截器、接插件的請求、寫網路相關測試時適用。規則(`Dio` 只在這裡建、
攔截器順序、重試只在這裡、網域與轉址、網路紀錄欄位)與閘門見 `app/AGENTS.md` § 網路;
為什麼這樣做,見 ADR 0012 §決定 1–2、ADR 0013 §決定 2、4。這裡只寫怎麼做。

## 目錄

```
lib/core/network/
source_http_client.dart # SourceHttpClientFactory、SourceHttpClient、SourceRequest、SourceResponse、RequestCancelled
interceptors.dart # part:五個攔截器、每次送出的狀態 _Attempt、只收自己 host 的 cookie jar
auth.dart # AuthRequirement、decideAuth、CredentialSource、NoCredentials
allowed_hosts.dart # AllowedHosts:manifest 網域比對(請求與 cookie 的 Domain 共用)
request_throttle.dart # RequestThrottle:併發上限+最小間隔
media_headers.dart # mediaRequestHeaders:媒體請求只留的 header
```

## 發一個請求

```dart
// 組裝點(PR 9 起):一個 factory,log 與認證來源給一次。
final factory = SourceHttpClientFactory(log: log);
final client = factory.create(
pluginId: manifest.id,
allowedHosts: manifest.allowedHosts,
retryPolicy: manifest.retryPolicy ?? const RetryPolicy(),
rateLimitPolicy: manifest.rateLimitPolicy,
);

final response = await client.send(
SourceRequest(
url,
headers: {'Referer': referer},
auth: AuthRequirement.userPreference,
),
abortTrigger: cancelled.future,
);
```

- `send` 丟的只有 `AppError` 與 `RequestCancelled`。網域不符、轉址出網域、`Location`
解析不了或超過 5 次是 `Unsupported`;未登入的 `required` 是 `AuthRequired`;傳輸錯誤是 `NetworkError`;
429 與帶 `Retry-After` 的 503 是 `RateLimited`(都已經照策略重試過)。
- 其他狀態碼照樣回 `SourceResponse`,插件在自己的邊界依錯誤對應表轉成 `AppError`
(`.trellis/spec/app/errors/index.md` § 音源的錯誤對應表)。
- `body` 是位元組;JSON 由插件自己 `utf8.decode`。
- 方法照 RFC 9110 大寫(`GET`),重試只看它判斷冪等;`get` 會被當成不冪等。
- 網址寫在 `lib/core/endpoints.dart`(`fmp_url_literal`),插件的網址在插件裡。

## 一次 `send` 的流程

1. 檢查網域(`AllowedHosts.allows`)。
2. 送出:`_Attempt` 帶新的紀錄 id 放進 `extra`,經五個攔截器到 adapter。
3. 失敗時依 `shouldRetry`/`delayFor` 決定要不要等了再送,回到 2(新的紀錄 id、
`retry` 加一)。
4. 301/302/303/307/308 帶 `Location`:檢查網域,照 RFC 9110 §15.4 改方法,跨 host
就拿掉 `Cookie`、`Authorization` 並改成 `AuthRequirement.never`(之後各跳沿用,
轉回原本的 host 也不再帶),回到 1。

## 改攔截器

- 攔截器都在 `interceptors.dart`(`source_http_client.dart` 的 `part`),順序在
`SourceHttpClientFactory.create` 的 `interceptors.addAll`。
- 攔截器之間共用的狀態放 `_Attempt`,不另開 `extra` 的鍵。
- reject 一律 `handler.reject(error, true)`;在 onRequest 或 onResponse 裡要讓請求失敗
時,把 `AppError` 放進 `DioException.error`(`_rejection`),`send` 會把它拆出來丟。
- 會等的攔截器(例如限流)先把要釋放的東西記進 `_Attempt` 再等:等的時候被取消,dio
直接走 onError,要在那裡釋放。
- 改了順序,`interceptors run in the ADR 0012 order` 應該會紅;新的前後關係如果看得出
行為差異,在那個測試加一條斷言。

## 測試

```dart
final harness = Harness(
(options) => switch (options.uri.path) {
'/a' => redirect('/b'),
_ => reply(200, body: '{}'),
},
credentials: FakeCredentials(headers: {'Cookie': 'SESSDATA=FAKE_SESSDATA_123'}),
retryPolicy: const RetryPolicy(maxRetries: 0),
);
await harness.get('https://example.test/a', auth: AuthRequirement.userPreference);
expect(harness.adapter.requests.last.headers['cookie'], ...);
expect(harness.records.single.fields['status'], 200);
```

- `test/core/network/harness.dart`:client 加上 `test/support/fake_http_adapter.dart`
的假 adapter(不聯網,記下送到最底層的 `RequestOptions`)、假時鐘(`waits` 記下每次
等待,等待立刻完成並把時鐘往前撥)、固定種子的 `Random`、`LogLevel.debug` 的 log。
- adapter 要模擬傳輸錯誤就在 handler 裡 `throw DioException.connectionTimeout(...)` 之類;
要模擬掛著的請求就回一個自己控制的 `Completer` 的 future。
- 等非同步進度用 `test/support/pump_until.dart` 的 `pumpUntil`/`settle`
(`fmp_test_waits`)。
- 網域在測試裡用 `example.test`、`cdn.example`(RFC 2606 保留網域),不用真實網站。
- 驗遮蔽時兩個出口都看:`harness.log.history` 與 `LogFile`(見
`.trellis/spec/app/logging/index.md` § 測試)。

## 媒體 header

媒體 client 延到 M6。播放後端(PR 10)拿到插件給的串流 headers 時先過
`mediaRequestHeaders`,再交給 just_audio/media_kit。要多放一個 header 時改
`mediaHeaderNames`,並在 `media_headers_test.dart` 加一個會留與一個不會留的案例。

## Quality Check

- `test/core/network/` 全綠;新行為在對應群組有案例。
- `lib/core/network/` 沒有 import 上層(`fmp_layer_imports`)、沒有網址字面值。
- 改了網路紀錄的欄位:`app/AGENTS.md` § 網路與 `network log` 群組一起改。
3 changes: 2 additions & 1 deletion .trellis/tasks/09-28-m1-skeleton-tracer/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,8 @@ app/
| § | 項目 | 決定 | 理由 |
|---|---|---|---|
| 3.2 | Flutter 版本 | `app/` 用 3.47.5(Dart 3.13.4),CI 的 `app` job 釘這版;舊專案 job 維持 3.47.1。本機升到 3.47.5,舊專案同一個 minor,照常建置 | ADR「當時的 stable」 |
| 3.4 | `AuthRequirement` | M1 建型別與宣告點(manifest、請求 DTO),認證攔截器在沒有 `CredentialStore` 時一律不注入;`CredentialStore` 與三種標記的注入測試在 M3 | B 站搜尋與解串流不需登入;宣告點是插件 API 的一部分,晚加會改 DTO |
| 3.4 | `AuthRequirement` | M1 建型別與宣告點(manifest、請求 DTO),認證攔截器在沒有 `CredentialStore` 時一律不注入;三種標記的注入測試在 PR 8 以假的認證來源寫,`CredentialStore` 在 M3 | B 站搜尋與解串流不需登入;宣告點是插件 API 的一部分,晚加會改 DTO |
| — | 媒體 client | 延到 M6。M1 在 PR 8 建媒體 header 政策(`mediaRequestHeaders`:只留 `Referer`、`User-Agent`、`Origin`、`Range`),PR 10 的播放後端拿到的串流 headers 一律先經過它 | M1 沒有使用者:播放後端自己抓串流網址,下載在 M6;M1 需要的只是「交給播放後端的 headers 不帶憑證」。ADR 0009「不寫空實作」 |
| 3.5 | `PermissionGateway` | M1 不建 | M1 沒有需要執行期權限的功能;ADR 0009「不寫空實作」 |
| 3.6 | 串流網址快取 | M1 不建;`StreamResolver` 每次呼叫插件 | ADR 0016 整包在 M2;兩首的佇列用不到 |
| 3.8 | log 檔格式 | 一開始就寫 JSON Lines(ADR 0025 §決定 3 的欄位),2MB×3 | 先寫純文字、M3 再改,等於改兩次;7 天保留仍在 M2 的維護清單 |
Expand Down
1 change: 1 addition & 0 deletions .trellis/tasks/09-28-m1-skeleton-tracer/implement.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@
- [ ] TypeScript 型別定義。
- [ ] `packages/plugin_contract/`:契約執行器、fixture 格式、`checks.json`。
- [ ] `test/fixtures/plugins/test_plugin/`:合成資料、本機音檔。
- [ ] 接真實 B 站時觀察:伺服器回不合法的 `Set-Cookie` 是否讓請求變成 `UnexpectedError`(`dio_cookie_manager` 的 `ignoreInvalidCookies` 預設 false;PR 8 檢查提出,沒有重現案例前不改)。
- [ ] 建立 `1morr/fmp-plugins`:
- `bilibili/` 的 `search`、`resolveStream`;
- 錄一次 fixture(真實連線,最少操作)。
Expand Down
3 changes: 2 additions & 1 deletion .trellis/tasks/09-28-m1-skeleton-tracer/task.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@
"09-29-platform-layer",
"09-29-drift-schema",
"09-29-logging-settings",
"09-29-error-model"
"09-29-error-model",
"09-29-network-layer"
],
"parent": "09-26-fmp-rewrite",
"relatedFiles": [],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{"file": "docs/adr/0012-network-layer-and-accounts.md", "reason": "Network layer, redirects, AuthRequirement"}
{"file": "docs/adr/0013-unified-error-model.md", "reason": "Transport error mapping and retry"}
{"file": ".trellis/spec/app/errors/index.md", "reason": "AppError and retry policy usage"}
{"file": ".trellis/spec/app/logging/index.md", "reason": "Log facade and redaction"}
{"file": ".trellis/spec/app/lints/index.md", "reason": "Lint rules incl. fmp_http_client_owner"}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{"file": "docs/adr/0012-network-layer-and-accounts.md", "reason": "Network layer, redirects, AuthRequirement"}
{"file": "docs/adr/0013-unified-error-model.md", "reason": "Transport error mapping and retry"}
{"file": ".trellis/spec/app/errors/index.md", "reason": "AppError and retry policy usage"}
{"file": ".trellis/spec/app/logging/index.md", "reason": "Log facade and redaction"}
{"file": ".trellis/spec/app/lints/index.md", "reason": "Lint rules incl. fmp_http_client_owner"}
83 changes: 83 additions & 0 deletions .trellis/tasks/archive/2026-09/09-29-network-layer/prd.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# 網路層(M1 PR 8)

父任務:`../09-28-m1-skeleton-tracer`(implement「8.」)。

依據:
- ADR 0012 §決定 1–2:API client、攔截器順序、轉址、`AuthRequirement`;
- ADR 0011 §決定 4:網路紀錄;
- ADR 0013 §決定 2、4:傳輸錯誤轉 `NetworkError`、只在網路層重試、併發上限與最小間隔;
- ADR 0015 §決定 5:fixture 錄製與重播接在 dio 最底層的 `HttpClientAdapter`,本 PR 讓它可以替換,PR 9 實作。

## 範圍調整(主對話決定)

**媒體 client 延到 M6。**
- M1 沒有使用者:播放後端自己抓串流網址,下載在 M6。
- M1 需要的是「交給播放後端的 headers 不帶憑證」。本 PR 做成媒體 header 政策,PR 10 使用。
- 理由:ADR 0009 的「不寫空實作」精神。這一條寫進父任務的 design §3。

## 做什麼

所有程式放在 `lib/core/network/`,`Dio(` 只准出現在這裡,`fmp_http_client_owner` 守。

1. **每插件一個 API client**:`SourceHttpClient`(名稱可調),由一個工廠依插件建立。
- 輸入:
- 插件 id;
- 允許的網域清單(manifest,PR 9);
- `RetryPolicy`、`RateLimitPolicy`(PR 7);
- 認證來源介面:M1 沒有 `CredentialStore`,用「沒有憑證」的實作;
- log 門面;
- 可替換的 `HttpClientAdapter`,預設為 dio 的 IO adapter。
- 攔截器順序照 ADR 0012 §決定 1:認證注入 → cookie 管理 → 錯誤對應 → 限流與退避 → 網路紀錄。實作形式照 dio 官方做法,最後的執行順序要有測試斷言。
2. **網域與轉址**:
- 請求的 host 必須符合允許清單:與清單項目相同,或是它的子網域(`.` 邊界);只准 `https`。不符合時不發請求,直接回 `Unsupported`,或一個明確的 AppError 子類。
- 轉址手動跟隨(`followRedirects: false`),每一跳都檢查網域,最多 5 跳;超過或出網域就失敗。
- 做法參考舊版 `lib/data/sources/source_url_policy.dart` 的 `resolveRedirects`。
- 跨網域的轉址不帶原請求的 `Cookie`、`Authorization`。
3. **認證**:
- `AuthRequirement` enum:`required`、`userPreference`、`never`(預設)。
- 判斷函式:輸入「是否已登入」「以登入身分瀏覽的開關」、標記,輸出三種結果:帶憑證、不帶、或不發請求並回 `AuthRequired`(`required` 且未登入時)。
- 認證攔截器只依這個結果注入。M1 的來源一律「未登入」,三種標記在三種狀態下的結果照 ADR 0012 §如何確認寫測試,用假的認證來源。
- `CredentialStore` 與登入在 M3。
4. **cookie**:`cookie_jar`+`dio_cookie_manager`,每插件一個記憶體 cookie jar。
- 匿名 cookie(例如 B 站 `buvid`)要跨重啟保存時,由插件寫進自己的 storage(`plugin_storage`,ADR 0014);網路層不另建持久化。
- 這個做法寫進 AGENTS.md。
5. **錯誤對應**:
- 傳輸層錯誤轉 `NetworkError`:逾時、連線失敗、TLS 失敗、被取消另外處理。
- HTTP 429,以及帶 `Retry-After` 的 503,轉 `RateLimited`,用 PR 7 的 `parseRetryAfter`。這是 HTTP 通用語意(RFC 6585 §4、RFC 9110 §15.6.4)。
- 其他狀態碼不在網路層判斷,把回應原樣交給插件,由插件在自己的邊界對應(ADR 0013 §決定 2)。
6. **重試與限流**:
- 用 PR 7 的 `shouldRetry`、`delayFor` 重試;時鐘與 `Random` 可注入。
- 每插件的併發上限與最小請求間隔(`RateLimitPolicy`)。
- 被取消的請求不重試。
7. **網路紀錄**:每個請求一筆摘要,經 log 門面以 `debug` 寫入,失敗時用 `warning`。
- 欄位:方法、host、path、遮過的 query、狀態、耗時、回應大小、插件 id、錯誤類型、是否帶了憑證、重試次數。
- 不記 body。
- 每筆有一個 id;產生的 `AppError` 帶上這個 id(PR 7 的網路紀錄 id 欄位)。
8. **媒體 header 政策**:一個純函數,輸入插件給的串流 headers,只保留 `Referer`、`User-Agent`、`Origin`、`Range`,其餘一律丟掉,特別是 `Cookie` 與 `Authorization`。
- ADR 0012 §如何確認的「媒體請求不帶 Cookie/Authorization」在 M1 由這個函數與 PR 10 的後端接線守。
9. **PR 7 的待辦**:未捕捉錯誤若是 `AppError`,改走 `log.report`。
10. **測試**:一律用假的 `HttpClientAdapter`,不聯網;零聯網防線照常生效。
- 攔截器順序;
- 網域:相同、子網域、`evil-bilibili.com` 不算、`http` 被拒;
- 轉址:5 跳內成功、第 6 跳失敗、出網域失敗、跨網域不帶 Cookie;
- `AuthRequirement` 的表;
- 錯誤對應:逾時轉 `NetworkError`;429 帶秒數與日期的 `Retry-After`;503 有與沒有 `Retry-After`;
- 重試:只重試冪等請求、次數上限、尊重 `Retry-After`(假時鐘);
- 限流:併發上限、最小間隔(假時鐘);
- 網路紀錄:欄位完整、query 裡的假憑證被遮、沒有 body、`AppError` 帶上紀錄 id;
- 媒體 header 政策;
- 未捕捉的 `AppError` 走 `report`。
11. **文件**:
- `app/AGENTS.md` 網路段;
- `.trellis/spec/app/network/index.md`(繁中);
- 父任務 design §3 補「媒體 client 延到 M6」一列。

## 驗收

- [ ] `app/`:
- format 通過;
- codegen 沒有變動;
- `dart analyze --fatal-infos`、`flutter analyze` 零問題;
- `flutter test` 全綠;
- 哨兵通過。
- [ ] 不是使用者看得到的改動,不需要實機驗證。真實連線在 PR 9 的 B 站插件驗證。
Loading
Loading