為什麼我們不再使用 SDK

@alvinsng
英語1 天前 · 2026年7月14日
435K
674
52
55
1.0K

TL;DR

Alvin Sng 解釋了為何他的團隊將廠商 SDK 替換為自訂的 HTTP 客戶端,藉此改善除錯流程、統一可觀測性並消除臃腫的依賴項。

我們已停止使用 Stripe、WorkOS 和 Slack 的客戶端 SDK,並正在逐步遷移其餘部分。取而代之的是,我們透過一個名為 HttpBaseClient 的小型包裝類別,直接呼叫它們的 REST API。

這聽起來有點反直覺——SDK 本來應該能節省時間。但你可能也聽過,AI 正將更多程式碼移植為原生執行。同樣的道理也適用在 SDK 上。為什麼?

  • SDK 隱藏了底層細節,例如 HTTP 回應標頭和原始回應主體,而這些對於正在除錯問題或向上游團隊發送調查細節的 Agent 來說至關重要。
  • SDK 期望收到正確的回應,但現實是防火牆、負載平衡器和閘道器會返回格式錯誤的回應。這掩蓋了我們需要的除錯細節。
  • SDK 繞過了集中入口點(用於重試、錯誤處理和可觀測性)的強制機制,這讓 Agent 可以重寫自己的模式。
  • SDK 攜帶了大量冗餘,通常是從 OpenAPI 自動生成的,而我們只使用了其中極小部分的端點。

想想 SDK 最初為何誕生。它們是因為公司想要讓整合變得簡單而產生:提供一個共享函式庫、節省客戶時間、並隱藏重複的整合工作。但 AI 改變了這條成本曲線。現在,整合 SDK 的工作量往往與直接呼叫 HTTP API 差不多,而且為我們實際使用的端點編寫一個專用的 REST 客戶端既便宜又容易,還能提供更好的統一可觀測性。

無休止的打地鼠遊戲

Alvin Sng - inline image

如此「優美」的 JSON 回應

這就是我們 Sentry 錯誤儀表板過去的樣子。我們會發現一小部分請求從某個程式碼路徑失敗,然後我們就修補那個路徑,以處理 SDK 對使用者造成的意外。每天都會有另一個程式碼路徑觸發非預期的錯誤;我們再修補那個,這場遊戲永無止境。

SDK 在一種常見的生產故障模式下非常脆弱:伺服器表示出了問題,但回應並不是 SDK 預期的 JSON 格式。

一個過載的 Nginx 閘道器會返回非預期的 HTML。Cloudflare 會封鎖一個請求。防火牆會彈出速率限制頁面。供應商 API 通常是 JSON,但它前面的東西並不總是供應商 API。

當這種情況發生時,許多 SDK 會嘗試解析回應,失敗並返回一個通用的解析錯誤,然後丟掉有用的部分。原始回應主體消失了,狀態文字被隱藏,HTTP 標頭通常無法以可用的方式返回。如果供應商支援要求從 HTTP 標頭中取得請求 ID,我們就沒轍了,因為 SDK 不會返回它。

Agent 濫用 SDK 的直接性

我們的客戶端 SDK 呼叫就像狂野西部。Stripe 有一種模式,WorkOS 有另一種,Slack 有它自己的怪癖。其他整合則使用原始的 fetch、SDK 呼叫、零散的重試邏輯,或者根本沒有重試邏輯。

SDK 讓錯誤的事情看起來很簡單。Agent 總是能直接跳到供應商客戶端,從任何路由呼叫 stripe.customers.create(...)。這感覺很有效率,但它繞過了認證、重試、指標、日誌和錯誤轉換應該存在的共享位置。我們的程式碼庫中散落著像這樣的閉包包裝:

typescript
1const response = await catchRateLimitError(() =>
2 stripe.customers.retrieve(stripeCustomerId)
3);

如果你漏掉了一個地方,忘記包裝 SDK 呼叫,那天你就慘了。對我們的產品來說,Stripe 的速率限制和 WorkOS 的速率限制意味著同一件事:上游要求我們放慢速度。但在型別層級,它們是完全不同的物件。有些程式碼捕捉 SDK 特定的例外,有些捕捉通用的 Error,有些則什麼都沒捕捉。這就變成了 Sentry 的打地鼠遊戲:修復一個呼叫點的 429 錯誤,然後等著同類失敗在其他地方出現。

SDK 很臃腫

NPM 套件 openai 和 anthropic-ai/sdk 是由 Stainless 從 OpenAPI 規範自動生成的。Stripe 也是從 Stripe 的 OpenAPI 規範生成的。這就是如何擴展一個大型 API 的公共 SDK 來支援數十種程式語言的方式。

但一個優秀的公共 SDK 必須服務於所有人。我們的後端不需要所有人的 SDK。它只需要我們的八個 Stripe 端點、WorkOS 使用者和組織端點,以及我們實際會呼叫的 Slack 方法。Stripe 是 6.5 MB,workos-inc/node 是 6.9 MB,slack/web-api 是 7.7 MB,而 linear/sdk 是 34 MB。在最極端的例子中,googleapis 達到 198 MB。

生成的 SDK 帶來了整個平台:數百個方法、多載、分頁輔助函式、重試行為、環境偵測、相容性墊片,以及那些因為某處還有人依賴而無法刪除的舊有介面。在我們自己的後端內部,這種通用性通常是冗餘。更糟的是,它擋在我們和網路線之間。

我們忘了 HTTP API 就是 API 合約

人們有時會把 HTTP API 視為較低層級的實作細節,而把 SDK 視為真正穩定的介面。公共 REST API 是一個合約。 供應商不能隨意破壞它。SDK 的作者也知道這一點,因為他們不能強迫每個客戶升級。許多客戶在生產環境中仍執行多年舊版的 SDK,這意味著舊的網路合約必須繼續運作。

我們自己的 HttpBaseClient

HttpBaseClient 是我們用來取代客戶端 SDK 的方案。提供者的子類別負責供應商特定的部分:基礎 URL、認證標頭、內容類型、錯誤對應,以及我們實際使用端點的窄方法。HttpBaseClient 則負責其餘部分:序列化、解析、傳輸錯誤、結構化日誌、指標、狀態對應和持續時間追蹤。這統一了可觀測性,讓每個供應商都遵循一致的標準。以下是簡化的架構:

typescript
1abstract class HttpBaseClient<TEndpoint extends string> {
2 protected abstract readonly baseUrl: string;
3
4 protected constructor(private readonly dependency: string) {}
5
6 protected abstract buildAuthHeaders(): Promise<Record<string, string>>;
7
8 protected async request<TBody, TResponse>(config: {
9 method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
10 path: string;
11 endpoint: TEndpoint;
12 body?: TBody;
13 }): Promise<TResponse> {
14 const url = `${this.baseUrl}${config.path}`;
15 const headers = await this.buildAuthHeaders();
16 const labels = {
17 dependency: this.dependency,
18 endpoint: config.endpoint,
19 method: config.method,
20 };
21 const start = performance.now();
22
23 logInfo('upstream request starting', labels);
24
25 try {
26 const response = await callWithMetrics(
27 () =>
28 fetch(url, {
29 method: config.method,
30 headers,
31 body: config.body === undefined ? undefined : JSON.stringify(config.body),
32 }),
33 this.dependency,
34 labels
35 );
36
37 const body = await parseBody(response);
38 if (!response.ok) throw this.mapHttpError(response, body);
39
40 logInfo('upstream request succeeded', {
41 ...labels,
42 statusCode: response.status,
43 durationMs: performance.now() - start,
44 });
45
46 return body as TResponse;
47 } catch (cause) {
48 logWarn('upstream request failed', {
49 ...labels,
50 durationMs: performance.now() - start,
51 cause,
52 });
53 throw cause;
54 }
55 }
56}
57
58// 然後 Stripe 包裝類別就變得小而明確:
59enum StripeEndpoint {
60 CustomersCreate = 'stripe/customers/create',
61}
62
63class StripeHttpClient extends HttpBaseClient<StripeEndpoint> {
64 protected readonly baseUrl = 'https://api.stripe.com';
65
66 constructor(private readonly apiKey: string) {
67 super(ClientVendor.Stripe);
68 }
69
70 createCustomer(body: { email: string; name: string }) {
71 return this.request<typeof body, Stripe.Customer>({
72 method: 'POST',
73 path: '/v1/customers',
74 endpoint: StripeEndpoint.CustomersCreate,
75 body,
76 });
77 }
78
79 // 更多端點放在這裡。
80}

關鍵就在這裡。這個類別並沒有試圖模擬所有 Stripe 的功能。它模擬的是我們希望每個供應商呼叫都具備的 HTTP 行為。Stripe 仍然使用表單編碼,WorkOS 仍然使用 Bearer 認證和 JSON 主體,Slack 仍然在 HTTP 200 下返回奇怪的 ok: false 行為。但我們後端的其餘部分看到的是統一一致的形態。

你可以在這裡查看更多關於 HttpBaseClient 的版本,為了更容易閱讀,已將其修改為通用的範例程式碼。

我們仍在使用 SDK 的地方

我們目前的方法是混合的:執行時使用自己的 HTTP 客戶端,但在型別仍能節省時間的地方保留 SDK。StripeHttpClient 可以返回 Stripe.Customer,SlackHttpClient 可以借用 slack/web-api 的引數型別,WorkOS 型別仍然可以描述網路回應。

我預期隨著時間推移,我們也會逐步淘汰這種做法。隨著 AI 在生成和維護我們所需精確請求和回應型別方面的能力越來越強,為了型別而保留整個 SDK 套件的理由會越來越弱。但執行時行為是痛點,所以遷移從那裡開始。

當 SDK 本身就是產品邊界,而不僅僅是 REST 的包裝時,我們仍然會使用 SDK。可觀測性是最明顯的例子。對 Sentry 而言,SDK 處理執行時檢測、錯誤捕獲、作用域傳播、釋出中繼資料以及我們不想自己從頭撰寫的整合。這與將供應商 SDK 用作普通後端 HTTP 呼叫的薄客戶端不同。

並非所有 API 都是基於 HTTP 的 REST,這沒問題。資料庫呼叫就是一個很好的例子。我們較低層級的抽象是 BaseClient:它為每個客戶端提供相同的指標、日誌和錯誤處理合約,同時允許子類別覆蓋其傳輸的「fetch」含義。

我們未來的方向

開發者會將 API 文件視為真正的整合指南,而 SDK 只是參考實作:認證、負載、分頁、重試和邊界情況的範本。下一代的「發布 SDK」可能會變成「發布 Agent 技能」,教導 Agent 如何正確呼叫 API、重用正確的模式,並避免將每個執行時呼叫都推送到供應商套件中。

一鍵儲存

使用 YouMind AI 深度閱讀爆款文章

保存原文、追問細節、總結觀點,並在一個 AI 工作空間裡把爆款文章沉澱成可複用筆記。

了解 YouMind
寫給創作者

把你的 Markdown 變成乾淨的 𝕏 文章

圖片上傳、表格、程式碼區塊,往 𝕏 上手動重排太痛苦。YouMind 把整篇 Markdown 一鍵轉成乾淨、可直接發佈的 𝕏 文章草稿。

試試 Markdown 轉 𝕏

更多可拆解樣本

近期爆款文章

探索更多爆款文章