← Blog

The Proper Way to Write API Clients

2026-01-23 Marcel Claramunt devapiclientpython

How to write API clients for simplicity, precision and ease of use.

Introduction

Details matter. Developer experience matters. Every unnecessary import, every unnecessary check on the docs because the type hints are lacking, every hard-to-understand feature, every hard-coded quirk the user can’t turn off—these matter.

So, some guidelines are obvious:

  1. Inputs shouldn’t require custom imports
  2. Annotate types precisely
  3. Avoid unnecessary complication
  4. Provide extra behavior optionally

Let’s unpack them and give examples one by one.

1. Inputs shouldn’t require custom imports

So simple, yet so commonly ignored. How often do you see this?

from sdk.deeply.nested.path.to.the.method.module import Parameters

await client.get_user(Parameters(email='...'))

I don’t want to walk your stupid repo at every god-damn call! No, give me a TypedDict instead, for pity’s sake. This way I still get the type safety, and no import required:

# SDK code
class Parameters(TypedDict):
	email: str
	
async def get_user(self, parameters: Parameters):
	...
	
# user code
await client.get_user({ 'email': '...' })

Isn’t that nicer?


2. Annotate types precisely

Nothing frustrates me more than an API “SDK” that looks like this:

async def get_users(**kwargs) -> list:
	return await request('GET', '/data/users', params=kwargs)

Thanks for your help! I was worrying integrating with the API would be a mess, but these precise annotations make it much easier!

LOL

But that’s not quite it. Correct type annotations may not tell the full picture either. Consider this:

class User(TypedDict):
	name: str
	...
	full_name: NotRequired[str]
	"""Only if return_type='full'"""
	
async def get_users(return_type: Literal['simple', 'full']) -> list[User]:
	...

Almost there, but not quite… We can narrow it further:

class SimpleUser(TypedDict):
	name: str
	...
	
class FullUser(SimpleUser):
	full_name: str
	
@overload
async def get_users(return_type: Literal['simple']) -> list[SimpleUser]:
	...
@overload
async def get_users(return_type: Literal['full']) -> list[FullUser]:
	...

Now we’re talking! Chef kiss.


3. Avoid unnecessary complication

Keep your implementation details to yourself, thank you. Nobody cares about them. Yet how often is one force-fed with this kind of thing?

from sdk.client import Client
from sdk.gateway import ApiGateway
from sdk.transport import HttpClient

client = Client(
	api_gateway=ApiGateway('api3.company.com'),
	transport_client=HttpClient(use_httpx=True),
)

Why?! Why do I have to go through convulsions just to instantiate your damn client? Can’t you pick an HTTP library for me? Can’t you pick an API endpoint? I don’t want to choose, nor care.

No, give me some solid defaults. I should only go through convulsions if I want something really custom:

from sdk import Client

client = Client.new()

And that’s it! If you want, you can optionally give me more options:

# SDK code
class Client:
	@classmethod
	def new(
		cls, *, api_gateway: str = DEFAULT_GATEWAY,
		transport: Literal['httpx', 'requests', 'websockets']
	):
		return cls(...)

4. Provide extra behavior optionally

You’re writing an API client. Don’t get fancy. I don’t need retry logic, I don’t need an exaggeratedly complicated exception hierarchy. I don’t need the SDK to make me a fucking coffee! Your mission is to make integration easy and type-checkable.

Some examples:

  1. Retries could be great! But make them opt-in:

    client = Client.new(retries={
        'max': 5,
        'delay': 2
    })
    
  2. Paging is nice. But not by default:

    users = await client.get_users(page=2) # normal, as exposed by the API
    
    async for page in client.get_users_paged(): # okay, as a separate thing
        for user in page:
            ...
    

Closing

And that’s it. It’s not complicated, just some solid basics. The principles are clear: simplicity, precision and ease of use.