kiwi0134's avatar

Best practices to properly implement external APIs?

Hello,

currently I'm building an app which makes use of multiple third party HTTP APIs.

I'm wondering what best practices there are, especially regarding handling rate limits and error handling. What would be a good way and where would be a great place to handle API errors? I definitely have to consider invalid user input which I cannot always validate, timeouts and probably even data inconsistencies.

Some weird quirks are, for example: If I want to create a resource, it returns a 201 Created with the data inside the response body. If that resource already exists, it returns a 204 No Content with an empty body. Especially this behavior makes it, in my eyes, a lot harder to create uniform responses from that API service method. I cannot simply return a DTO, as there might be no data to fill it with.

Retrieving the 204 is expected sometimes, as in this case I cannot possibly know if that resource already exists. Both responses are OK and fine for my application, though. So I don't think that handling this with exceptions is an adequate solution.

The next thing is rate limiting: The APIs rate limit. There's no fixed limit, so I have to listen to the headers in the responses (which should be done in any case, though). I outsource most API calls to queue jobs, to run them in the background for obvious reasons. Rate limits are mostly attached to a route, not a resource. Of course I can check the response and delay this single job until the timestamp provided inside the Retry-After header. But that wouldn't prevent other queued jobs to the same route from being executed before that point in time that Retry-After told me.

How do you properly handle calls to external APIs?

0 likes
4 replies
martinbean's avatar

How do you properly handle calls to external APIs?

@kiwi0134 Unfortunately, there isn’t one single way to handle all third party APIs. Different API vendors interpret things like the HTTP spec differently… and sometimes not at all. Some will use rate limiting; others won’t. Those who do implement rate limiting, will do so in different ways, and so on.

You’re on the right track in that you’re identifying the “characteristics” of the API you’re interacting with. The important thing is to create a “layer” between the API and your application. You should have some sort of mapping layer that does the API requests, but then converts the data to instances of classes within your application that models whatever entities you’re working with. If you support multiple APIs for the same entity, then you’d just have multiple mapping layers that all convert the API-specific representations, to the canonical instance in your application.

Think of payment gateways for example. Different payment gateways will use different terminology for things like a “charge”. So you could create a Charge class in your application:

use App\Enums\ChargeProvider;
use Money\Money;

class Charge
{
    public readonly ChargeProvider $provider;
    public readonly string $providerId;
    public readonly Money $amount;

    public function __construct(ChargeProvider $provider, string $id, Money $amount)
    {
        $this->provider = $provider;
        $this->providerId = $providerId;
        $this->amount = $amount;
    }
}

When you then process a charge with say, Stripe, you’d have some class that converts a Stripe charge object to an instance of your application’s Charge class. Same if you process a charge with an alternative gateway such as PayPal. Your application works with its own representation of things, but you have another layer that does the actual interaction with the external APIs and converts them to your application’s internal representation.

For things like rate limiting or API errors, you can look at architecture patterns such as “circuit breakers”. This is basically just wrapping an interaction and terminating it if a precondition fails, such as an error from the API or the request exceeds a predefined timeout threshold.

For things like receiving a 201 Created response versus a 204 No Content response, this is something you need to handle. Usually you receive a 201 Created response from endpoints where you’re creating entirely new resources, so I’m not sure what endpoint you’d hit to create a resource that it would then respond with, “Oh, here’s a No Content response because we already have that resource”. Either way, if you do receive a 204 No Content response then you’re going to need some logic that then loads the existing resource and returns that to your application in order to construct a class representing that entity. But if you’re expecting a new resource to be created (rather than a response saying it already exists), then that may be something you need to model in your application.

kiwi0134's avatar

@martinbean Thank you for your very detailed reply! I'm sorry, I kinda have forgotten this thread, as development was halted temporarily.

Abstracting third party APIs (and possibly SDKs) is what I'm doing right now. My primary issue is: handling rate limits.

Most of my API calls are made in jobs. There can be many jobs with this, as they're triggered by user interactions. But the rate limits are application wide. I struggle with a clean implementation of how to handle that. Of course, I could simply catch any 429 response, set a cache key with the timeout and check for it at the beginning of every job. If the retry-after timestamp is in the future, delay this job until then and push it back to the queue.

Though it might be a good start, it doesn't feel quite right as I'd have a lot of duplicated code and boiler plate around most of my jobs. Maybe queue events (before) might be a way to solve this. Maybe an attribute with a cache key to check for, which I can add to the job?

It doesn't sound too bad. Kinda like:

#[RateLimited(key: 'cache-key')]
class MyJob {}

But then there's the hurdle of registering the fail and setting the rate limit. At least I have no idea to cleanly do that right now. This might be a good starting point, though.

martinbean's avatar
Level 80

@kiwi0134 In your API clients, I’d just throw some sort of “rate limited” exception that also has a method to get the time the request should be attempted again. You can then use this to release the job back on the queue, and only be attempted after the rate limit has expired.

public function handle(ApiClient $api): void
{
    try {
        $api->someOperation();
    } catch (RateLimitedException $e) {
        // API request was rate-limited
        // Release job back on queue, and process after rate limit expires
        // Assume $e->getRateLimitExpires() returns a Carbon instance

        $this->release($e->getRateLimitExpires()->diffInSeconds());
    }
}
2 likes
kiwi0134's avatar

@martinbean Wow, this is so stupidly simple and effective. Thank you very much. No idea why I was trying to over-engineer this.

1 like

Please or to participate in this conversation.