เราหยุดใช้ Stripe, WorkOS และ Slack client SDK แล้ว และกำลังทยอยย้ายส่วนที่เหลือออกไป แทนที่ด้วยการเรียก REST APIs โดยตรงผ่านคลาส wrapper ขนาดเล็กที่เราเรียกว่า HttpBaseClient
ฟังดูเหมือนถอยหลังเข้าคลอง SDK ควรจะช่วยประหยัดเวลาให้คุณ แต่คุณคงได้ยินเรื่อง AI ที่พอร์ตโค้ดให้รันแบบเนทีฟมากขึ้นเรื่อยๆ แนวคิดเดียวกันนี้ใช้กับ SDK ด้วย ทำไมล่ะ?
- SDK ซ่อนรายละเอียดดิบๆ เช่น HTTP response headers และ raw response bodies ซึ่งเป็นสิ่งสำคัญสำหรับเอเจนต์ที่กำลังดีบักปัญหาหรือส่งรายละเอียดให้ทีม upstream สอบสวน
- SDK คาดหวังการตอบสนองที่ถูกต้อง แต่ในความเป็นจริงแล้ว response ที่ผิดรูปกลับมาจากไฟร์วอลล์, load balancer และ gateway ซึ่งเป็นการปิดบังรายละเอียดการดีบักที่เราต้องการ
- SDK ข้าม forcing function ของจุดศูนย์กลางสำหรับ retries, error handling และ observability ซึ่งทำให้เอเจนต์สามารถเขียนรูปแบบของตัวเองขึ้นมาใหม่ได้
- SDK มี bloat จำนวนมาก ซึ่งมักจะถูกสร้างขึ้นอัตโนมัติจาก OpenAPI ในขณะที่เราใช้เพียง endpoints เล็กน้อยเท่านั้น
ลองคิดดูว่าเหตุใด SDK จึงถือกำเนิดขึ้น พวกมันเกิดเพราะบริษัทต่างๆ ต้องการให้การนำไปใช้งานรู้สึกง่าย: ส่ง shared library, ประหยัดเวลาของลูกค้า และซ่อนงาน integration ที่ซ้ำซาก แต่ AI ได้เปลี่ยนเส้นต้นทุนนั้นไปแล้ว การ integrate กับ SDK ในปัจจุบันมักใช้工作量พอๆ กับการเรียก HTTP API โดยตรง และการเขียน REST client ที่ปรับแต่งเฉพาะสำหรับ endpoints ที่เราใช้เท่านั้นพร้อมกับให้ observability ที่ดีกว่าแบบครบวงจรนั้นมีต้นทุนถูก
เกม Whack-a-Mole ที่ไม่มีวันจบ

"JSON" responses สุดสวย
นี่คือลักษณะของ Sentry error dashboard ที่เราเคยเห็น เราจะพบว่ามีเปอร์เซ็นต์เล็กน้อยของ requests ที่ล้มเหลวจาก code path บางอัน จากนั้นเราจะ monkey-patch เฉพาะ path นั้นเพื่อจัดการกับความผิดพลาดของ SDK ให้กับผู้ใช้ ในวันถัดไป code path อื่นก็จะสะดุดกับ error ที่ไม่คาดคิดอีก เราก็จะ patch อันนั้นอีก และเกมนี้ก็ไม่มีวันสิ้นสุด
SDK เปราะบางในโหมดความล้มเหลวในการผลิตทั่วไป: เซิร์ฟเวอร์บอกว่ามีบางอย่างผิดปกติ แต่ response ไม่ใช่รูปทรง JSON ที่ SDK คาดหวัง
Nginx gateway ที่โอเวอร์โหลดจะส่ง HTML ที่ไม่คาดคิด Cloudflare บล็อก request ไฟร์วอลล์แสดงหน้า rate-limit API ของผู้ขายมักจะเป็น JSON แต่สิ่งที่อยู่ข้างหน้านั้นไม่ใช่ API ของผู้ขายเสมอไป
เมื่อเกิดเหตุการณ์นั้น SDK หลายตัวพยายาม parse response, ล้มเหลวด้วย generic parsing error และทิ้งส่วนที่มีประโยชน์ไป raw body หายไป status text ถูกฝัง HTTP headers มักจะไม่ถูกส่งกลับมาในรูปแบบที่ใช้งานได้ หากฝ่ายสนับสนุนของผู้ขายขอ request ID จาก HTTP header เราจะหมดหวังเพราะ SDK ไม่ส่งค่านั้นมา
เอเจนต์ใช้ประโยชน์จากความตรงไปตรงมาของ SDK ในทางที่ผิด
การเรียก client SDK ของเราเป็นเหมือนดินแดนตะวันตกที่ไร้กฎหมาย Stripe มีรูปแบบหนึ่ง WorkOS มีอีกรูปแบบหนึ่ง Slack มีลักษณะเฉพาะของตัวเอง Integration อื่นๆ มี raw fetch, การเรียก SDK, retry logic แบบเฉพาะกิจ หรือไม่มี retry logic เลย
SDK ทำให้สิ่งที่ผิดดูเหมือนง่าย เอเจนต์สามารถตรงไปยัง client ของผู้ขายและเรียก stripe.customers.create(...) จาก route ใดก็ได้ ซึ่งดูเหมือนมีประสิทธิภาพ แต่มันข้ามจุดที่ใช้ร่วมกันซึ่ง auth, retries, metrics, logs และ error translation ควรจะอยู่ เรามี closure wrappers แบบนี้เกลื่อนใน codebase:
1const response = await catchRateLimitError(() =>2 stripe.customers.retrieve(stripeCustomerId)3);
ถ้าคุณพลาดจุดใดจุดหนึ่งและลืม wrap การเรียก SDK คุณจะมีวันที่แย่ Stripe rate limit และ WorkOS rate limit มีความหมายเหมือนกันสำหรับผลิตภัณฑ์ของเรา: upstream กำลังขอให้เราลดความเร็วลง แต่ในระดับ type พวกมันเป็นวัตถุที่แตกต่างกันโดยสิ้นเชิง โค้ดบางส่วนจับ exception เฉพาะของ SDK บางส่วนจับ generic Error บางส่วนไม่จับอะไรเลย ซึ่งกลายเป็นเกม Whack-a-Mole ของ Sentry: แก้ไข 429 หนึ่งครั้งที่ call site หนึ่ง รอให้ความล้มเหลวชนิดเดียวกันเกิดขึ้นที่อื่น
SDK มี bloat
แพ็คเกจ NPM openai และ anthropic-ai/sdk ถูกสร้างขึ้นอัตโนมัติจาก OpenAPI specs โดย Stainless Stripe ก็ถูกสร้างขึ้นจาก OpenAPI spec ของ Stripe เช่นกัน นั่นคือวิธีที่คุณปรับขนาดการบำรุงรักษา SDK สาธารณะสำหรับ API ขนาดใหญ่ในภาษาโปรแกรมต่างๆ มากมาย
แต่ SDK สาธารณะที่ยอดเยี่ยมต้องให้บริการทุกคน Backend ของเราไม่ต้องการ SDK ของทุกคน มันต้องการ Stripe แปด endpoints, WorkOS user และ org endpoints และเมธอด Slack ที่เราเรียกจริงๆ Stripe มีขนาด 6.5 MB, workos-inc/node มีขนาด 6.9 MB, slack/web-api มีขนาด 7.7 MB และ linear/sdk มีขนาด 34 MB ในกรณีที่ไกลที่สุด googleapis มีขนาดถึง 198 MB
Generated SDKs นำแพลตฟอร์มทั้งหมดมาด้วย: หลายร้อยเมธอด, overloads, pagination helpers, retry behavior, environment detection, compatibility shims และพื้นผิวเก่าที่ไม่สามารถหายไปได้เพราะยังมีคนบางที่ที่ยังพึ่งพามันอยู่ ภายใน backend ของเราเอง ความกว้างขวางนั้นมักจะเป็น bloat ยิ่งกว่านั้น มันยังขวางอยู่ระหว่างเรากับสายไฟ
เราลืมไปว่า HTTP APIs คือสัญญา API
บางครั้งผู้คนพูดถึง HTTP APIs ราวกับว่ามันเป็นรายละเอียดการใช้งานระดับล่าง และ SDK เป็นอินเทอร์เฟซที่เสถียรจริงๆ REST API สาธารณะคือสัญญา ผู้ขายไม่สามารถละเมิดมันได้ง่ายๆ ผู้เขียน SDK ก็รู้เช่นกัน เพราะพวกเขาไม่สามารถบังคับให้ลูกค้าทุกคนอัปเกรดได้ ลูกค้าจำนวนมากยังคงใช้ SDK เวอร์ชันเก่ามาหลายปีในโพรดักชัน ซึ่งหมายความว่าสัญญา wire แบบเก่าต้องทำงานต่อไปอยู่ดี
HttpBaseClient ของเราเอง
HttpBaseClient คือสิ่งทดแทน client SDK ของเรา Subclass ของ Provider จะจัดหาชิ้นส่วนเฉพาะของผู้ขาย: base URL, auth headers, content type, error mapping และเมธอดที่จำกัดสำหรับ endpoints ที่เราใช้จริง HttpBaseClient เป็นเจ้าของส่วนที่เหลือ: serialization, parsing, transport errors, structured logs, metrics, status mapping และ duration tracking สิ่งนี้รวม observability เข้าด้วยกันเพื่อให้ผู้ขายทุกรายปฏิบัติตามมาตรฐานที่สอดคล้องกัน นี่คือโครงสร้างแบบย่อ:
1abstract class HttpBaseClient<TEndpoint extends string> {2 protected abstract readonly baseUrl: string;34 protected constructor(private readonly dependency: string) {}56 protected abstract buildAuthHeaders(): Promise<Record<string, string>>;78 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();2223 logInfo('upstream request starting', labels);2425 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 labels35 );3637 const body = await parseBody(response);38 if (!response.ok) throw this.mapHttpError(response, body);3940 logInfo('upstream request succeeded', {41 ...labels,42 statusCode: response.status,43 durationMs: performance.now() - start,44 });4546 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}5758// จากนั้น Stripe wrapper ก็มีขนาดเล็กและชัดเจน:59enum StripeEndpoint {60 CustomersCreate = 'stripe/customers/create',61}6263class StripeHttpClient extends HttpBaseClient<StripeEndpoint> {64 protected readonly baseUrl = 'https://api.stripe.com';6566 constructor(private readonly apiKey: string) {67 super(ClientVendor.Stripe);68 }6970 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 }7879 // เพิ่ม endpoints ได้ที่นี่80}
นั่นคือเคล็ดลับ คลาสนี้ไม่ได้พยายามจำลอง Stripe ทั้งหมด มันจำลองพฤติกรรม HTTP ที่เราต้องการให้การเรียกผู้ขายทุกครั้งมี Stripe ยังคงได้รับการเข้ารหัสแบบฟอร์ม WorkOS ยังคงได้รับ bearer auth และ JSON bodies Slack ยังคงได้รับพฤติกรรม ok: false แปลกๆ บน HTTP 200 แต่ backend ที่เหลือของเราเห็นรูปร่างที่สอดคล้องกันเพียงหนึ่งเดียว
คุณสามารถดูเวอร์ชันยาวขึ้นของ HttpBaseClient ของเราที่นี่ ซึ่งถูกปรับเปลี่ยนให้เป็น generic และอ่านง่ายขึ้นเพื่อใช้เป็นตัวอย่างโค้ด
จุดที่เรายังคงใช้ SDK
แนวทางปัจจุบันของเราเป็นแบบไฮบริด: ใช้ HTTP client ของเราเองในรันไทม์ แต่ยังคงรักษา SDK ไว้ในที่ที่ type ของมันยังช่วยประหยัดเวลา StripeHttpClient สามารถส่งคืน Stripe.Customer, SlackHttpClient สามารถยืมประเภทอาร์กิวเมนต์ของ slack/web-api และประเภทของ WorkOS ยังคงสามารถอธิบาย response ทาง wire ได้
ฉันคาดว่าเราจะค่อยๆ เลิกใช้ส่วนนี้เช่นกัน เมื่อ AI มีความสามารถในการสร้างและบำรุงรักษาประเภท request และ response ที่แน่นอนตามที่เราต้องการได้ดีขึ้น เหตุผลในการเก็บแพ็คเกจ SDK ทั้งหมดไว้เพียงเพื่อ types ก็จะยิ่งอ่อนแอลง แต่พฤติกรรมรันไทม์เป็นส่วนที่เจ็บปวด ดังนั้นนั่นคือจุดเริ่มต้นของการย้าย
เรายังคงใช้ SDK เมื่อ SDK เป็นขอบเขตของผลิตภัณฑ์ ไม่ใช่แค่ wrapper รอบ REST observability เป็นตัวอย่างที่ชัดเจนที่สุด สำหรับ Sentry, SDK จะจัดการ runtime instrumentation, error capture, scope propagation, release metadata และ integrations ที่เราไม่อยากทำเอง นั่นแตกต่างจากการใช้ SDK ของผู้ขายเป็น thin client สำหรับการเรียก HTTP backend ทั่วไป
ไม่ใช่ทุก API จะเป็น REST บน HTTP และนั่นก็ไม่เป็นไร การเรียกฐานข้อมูลเป็นตัวอย่างที่ดี abstraction ระดับต่ำกว่าของเราคือ BaseClient: มันให้ client แต่ละตัวมี metrics, logging และ error-handling contract เดียวกัน ในขณะที่อนุญาตให้ child class แทนที่ความหมายของ "fetch" สำหรับ transport ของมัน
ทิศทางที่เรากำลังมุ่งหน้าไป
นักพัฒนาจะถือว่าเอกสาร API เป็นแนวทางการ integration ที่แท้จริง และถือว่า SDK เป็น reference implementations: แม่แบบสำหรับ auth, payloads, pagination, retries และกรณีขอบ เวอร์ชันถัดไปของ "ส่ง SDK" อาจเป็น "ส่ง agent skill" ที่สอนเอเจนต์ถึงวิธีการเรียก API อย่างถูกต้อง ใช้รูปแบบที่ถูกต้องซ้ำ และหลีกเลี่ยงการผลักดันการเรียก rันไทม์ทุกครั้งผ่านแพ็คเกจของผู้ขาย





