Why we stopped using SDKs

@alvinsng
อังกฤษ23 ชั่วโมงที่ผ่านมา · 14 ก.ค. 2569
435K
674
52
55
1.0K

TL;DR

Alvin Sng explains why his team replaced vendor SDKs with a custom HTTP client to improve debugging, unify observability, and eliminate dependency bloat.

We stopped using the Stripe, WorkOS, and Slack client SDKs and are in the process of migrating the rest away. Instead, we call their REST APIs directly through a small wrapper class we call HttpBaseClient.

That sounds backwards. SDKs are supposed to save you time. But you’ve been hearing about AI porting more code to run natively. The same idea applies to SDKs. Why?

  • SDKs hide the raw details, like HTTP response headers and raw response bodies, which are critical for agents that are debugging issues or sending details to the upstream team for investigation.
  • They expect proper responses, but in reality malformed responses come back from firewalls, load balancers, and gateways. That masks the debugging details we need.
  • SDKs bypass the forcing function of a centralized entry point for retries, error handling, and observability, which lets agents rewrite their own patterns.
  • They carry heavy bloat, usually auto-generated from OpenAPI, when we only use a tiny subset of endpoints.

Think about why SDKs started. They were born because companies wanted adoption to feel easy: ship a shared library, save customers time, and hide the repetitive integration work. But AI changed that cost curve. Integrating with an SDK is now often as much work as calling the HTTP API directly, and it is cheap to write a tailored REST client for exactly the endpoints we use while providing better unified observability.

The endless game of whack-a-mole

Alvin Sng - inline image

Such lovely "JSON" responses

This is what our Sentry error dashboard used to look like. We would find that a small percent of requests failed from some code path, then we would monkey-patch just that path to handle the SDK mishap for the user. Each day another code path would trip an unexpected error; we would patch that one too, and the game never ended.

SDKs are fragile in a common production failure mode: the server says something went wrong, but the response is not the JSON shape the SDK expected.

An overloaded Nginx gateway returns unexpected HTML. Cloudflare blocks a request. A firewall puts up a rate-limit page. The vendor API is usually JSON, but the thing in front of it is not always the vendor API.

When that happens, many SDKs try to parse the response, fail with a generic parsing error, and throw away the useful bits. The raw body is gone. The status text is buried. The HTTP headers often are not returned in a usable way. If vendor support asks for a request ID from an HTTP header, we’re out of luck because the SDK does not return it.

Agents abuse the directness of SDKs

Our client SDK calls were the wild west. Stripe had one pattern. WorkOS had another. Slack had its own quirks. Other integrations had raw fetch, SDK calls, one-off retry logic, or no retry logic at all.

SDKs made the wrong thing look easy. An agent could always go directly to the vendor client and call \stripe.customers.create(...)\ from any route. That feels productive, but it bypasses the shared place where auth, retries, metrics, logs, and error translation should live. We had closure wrappers like this littered in our codebase:

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

If you missed a spot and forgot to wrap the SDK call, you had a bad day. A Stripe rate limit and a WorkOS rate limit mean the same thing to our product: the upstream is asking us to slow down. But at the type level, they were totally different objects. Some code caught SDK-specific exceptions. Some caught generic Error. Some caught nothing. That turned into Sentry whack-a-mole: fix one 429 at one call site, wait for the same class of failure somewhere else.

SDKs are bloated

NPM packages openai and anthropic-ai/sdk are auto-generated from OpenAPI specs by Stainless. Stripe is generated from Stripe's OpenAPI spec too. That is how you scale the maintenance of a public SDK for a large API across dozens of programming languages.

But a great public SDK has to serve everyone. Our backend does not need everyone's SDK. It needs our eight Stripe endpoints, our WorkOS user and org endpoints, and the Slack methods we actually call. Stripe is 6.5 MB, workos-inc/node is 6.9 MB, slack/web-api is 7.7 MB, and linear/sdk is 34 MB. In the far extreme, googleapis reachs 198 MB.

Generated SDKs bring the whole platform with them: hundreds of methods, overloads, pagination helpers, retry behavior, environment detection, compatibility shims, and old surfaces that cannot disappear because someone, somewhere, still depends on them. Inside our own backend, that generality is usually bloat. Worse, it sits between us and the wire.

We forget HTTP APIs are API contracts

People sometimes talk about HTTP APIs as if they are lower-level implementation details and SDKs are the real stable interface. The public REST API is a contract. Vendors cannot casually break it. SDK authors know this too, because they cannot force every customer to upgrade. Many customers keep running years-old SDK versions in production, which means the old wire contract has to keep working anyway.

Our own HttpBaseClient

HttpBaseClient is our replacement for client SDKs. Provider subclasses supply the vendor-specific pieces: base URL, auth headers, content type, error mapping, and narrow methods for the endpoints we actually use. HttpBaseClient owns the rest: serialization, parsing, transport errors, structured logs, metrics, status mapping, and duration tracking. This unifies observability so every vendor follows consistent standards. Here is the shape, simplified:

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// Then a Stripe wrapper becomes small and explicit:
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 // More endpoints go here.
80}

That is the trick. The class does not try to model all of Stripe. It models the HTTP behavior we want every vendor call to have. Stripe still gets form encoding. WorkOS still gets bearer auth and JSON bodies. Slack still gets its odd ok: false behavior on HTTP 200. But the rest of our backend sees one consistent shape.

You can view a longer version of what our HttpBaseClient looks like here, modified to be generic and easier to read as example code.

Where we still use SDKs

Our current approach is hybrid: use our own HTTP client at runtime, but keep SDKs around where their types still save time. StripeHttpClient can return Stripe.Customer, SlackHttpClient can borrow slack/web-api argument types, and WorkOS types can still describe the wire response.

I expect us to phase this out over time too. As AI gets better at generating and maintaining the exact request and response types we need, the case for keeping a whole SDK package just for types gets weaker. But runtime behavior is the painful part, so that is where the migration starts.

We still use SDKs when the SDK is the product boundary, not just a wrapper around REST. Observability is the clearest example. For Sentry, the SDK handles runtime instrumentation, error capture, scope propagation, release metadata, and integrations we would not want to hand-roll. That is different from using a vendor SDK as a thin client for ordinary backend HTTP calls.

Not every API is REST over HTTP, and that is fine. Database calls are a good example. Our lower-level abstraction is BaseClient: it gives each client the same metrics, logging, and error-handling contract, while allowing a child class to override what "fetch" means for its transport.

Where we're headed

Developers will treat API docs as the real integration guide and SDKs as reference implementations: templates for auth, payloads, pagination, retries, and edge cases. The next version of "ship an SDK" may be "ship an agent skill" that teaches agents how to call the API correctly, reuse the right patterns, and avoid pushing every runtime call through a vendor package.

บันทึกในคลิกเดียว

อ่านบทความไวรัลเชิงลึกด้วย AI ใน YouMind

บันทึกแหล่งที่มา ถามคำถามที่ตรงประเด็น สรุปข้อโต้แย้ง และเปลี่ยนบทความไวรัลให้เป็นโน้ตที่นำกลับมาใช้ได้ใน AI เวิร์กสเปซเดียว

สำรวจ YouMind
สำหรับครีเอเตอร์

เปลี่ยน Markdown ของคุณให้เป็นบทความ 𝕏 ที่สะอาดตา

เวลาคุณเผยแพร่งานเขียนยาวของตัวเอง การจัดรูปแบบรูปภาพ ตาราง และบล็อกโค้ดให้เข้ากับ 𝕏 นั้นน่าปวดหัว YouMind เปลี่ยนร่าง Markdown ทั้งฉบับให้เป็นบทความ 𝕏 ที่สะอาดตาและพร้อมโพสต์ทันที

ลอง Markdown เป็น 𝕏

แพตเทิร์นให้ถอดรหัสเพิ่มเติม

บทความไวรัลล่าสุด

สำรวจบทความไวรัลเพิ่มเติม