Integração com APIs de terceiros no Laravel: padrões, resiliência, testes e segurança

Integração com APIs de terceiros no Laravel: padrões, resiliência, testes e segurança

Quase todo sistema Laravel em produção conversa com alguma API de fora: gateway de pagamento, emissor de nota fiscal, serviço de e-mail, CRM, modelo de IA. A chamada em si cabe em uma linha com Http::get(). O trabalho de verdade está no que acontece quando essa API demora, devolve um erro que ninguém previu, muda o formato da resposta ou simplesmente sai do ar.

Este artigo reúne práticas para tratar integrações como parte séria da aplicação: onde colocar o código, quais padrões de projeto ajudam, como configurar timeout e retry, como sobreviver à indisponibilidade do fornecedor, como tratar erros, o que muda na segurança e como testar tudo sem depender da API real. Os exemplos usam o Laravel 13 e uma API fictícia de cotações.

Por que integração precisa de estrutura

Uma chamada HTTP solta no controller funciona no primeiro dia. O problema aparece depois. A URL e o token ficam repetidos em vários arquivos, cada lugar trata erro de um jeito, ninguém sabe quantas chamadas o sistema faz ao fornecedor, e trocar de fornecedor vira uma caça ao Http:: pelo projeto inteiro.

Existe também uma diferença de natureza. Uma chamada remota não se comporta como uma chamada de função. Martin Fowler lembra que ela pode falhar ou ficar pendurada até estourar o tempo limite, e que muitos clientes esperando por um fornecedor travado esgotam recursos e derrubam outros sistemas em cascata.

Toda a estrutura proposta a seguir parte dessa premissa: a API externa vai falhar em algum momento, e a aplicação precisa saber o que fazer quando isso acontecer.

Configuração: credenciais fora do código

O primeiro passo é tirar URL, token e tempos limite do código. O Laravel já tem um lugar para isso: o arquivo config/services.php, alimentado por variáveis de ambiente.

// config/services.php
'quotes' => [
    'base_url' => env('QUOTES_API_URL'),
    'token' => env('QUOTES_API_TOKEN'),
    'timeout' => (int) env('QUOTES_API_TIMEOUT', 5),
    'webhook_secret' => env('QUOTES_WEBHOOK_SECRET'),
],
# .env
QUOTES_API_URL=https://api.cotacoes.example
QUOTES_API_TOKEN=
QUOTES_API_TIMEOUT=5
QUOTES_WEBHOOK_SECRET=

Duas regras simples. O código lê config('services.quotes.token') e nunca chama env() direto, porque depois do php artisan config:cache o .env deixa de ser carregado e env() fora dos arquivos de configuração passa a devolver nulo. E o token de produção não entra no repositório nem no .env.example, que deve trazer só o nome da variável.

Se o fornecedor oferece ambiente de homologação (sandbox), use credenciais separadas por ambiente. Isso evita que um teste local gere uma cobrança real ou dispare e-mail para cliente.

Padrões de projeto que ajudam

Não existe um padrão único para integração, mas quatro ideias resolvem a maior parte dos problemas e combinam bem entre si.

Uma interface para o que a aplicação precisa

A regra de negócio não deveria saber que existe HTTP, JSON ou token. Ela precisa de uma cotação. Comece por um contrato escrito na língua do seu domínio:

<?php

namespace App\Services\Quotes;

use App\DataTransferObjects\Quote;

interface QuoteProvider
{
    /**
     * @throws QuoteProviderException
     */
    public function latest(string $symbol): Quote;
}

É o princípio da inversão de dependência na prática. O resto do sistema depende da interface, e a implementação que fala com a API fica isolada num canto. Trocar de fornecedor passa a significar escrever outra implementação, sem mexer em controllers, jobs ou regras de negócio.

Gateway e Adapter: uma classe por fornecedor

A implementação concreta faz dois papéis. É um gateway, porque concentra todo o acesso ao serviço externo num só lugar, e é um adapter, porque traduz o formato do fornecedor para o formato da sua aplicação.

<?php

namespace App\Services\Quotes;

use App\DataTransferObjects\Quote;
use Carbon\CarbonImmutable;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\RequestException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Throwable;

final class HttpQuoteProvider implements QuoteProvider
{
    public function latest(string $symbol): Quote
    {
        try {
            $response = $this->client()->get('/quotes/'.rawurlencode($symbol));
        } catch (ConnectionException $e) {
            throw new QuoteProviderUnavailable('Serviço de cotações não respondeu.', previous: $e);
        }

        if ($response->failed()) {
            $this->logFailure($response);

            throw $response->serverError() || $response->tooManyRequests()
                ? new QuoteProviderUnavailable("Serviço de cotações respondeu {$response->status()}.")
                : new QuoteProviderException("Requisição recusada com status {$response->status()}.");
        }

        return $this->toQuote($response->json() ?? []);
    }

    private function client(): PendingRequest
    {
        return Http::baseUrl(config('services.quotes.base_url'))
            ->withToken(config('services.quotes.token'))
            ->acceptJson()
            ->connectTimeout(3)
            ->timeout(config('services.quotes.timeout'))
            ->withOptions(['allow_redirects' => false])
            ->retry([200, 500], when: fn (Throwable $e) => $this->isTransient($e), throw: false);
    }

    private function isTransient(Throwable $e): bool
    {
        if ($e instanceof ConnectionException) {
            return true;
        }

        return $e instanceof RequestException
            && ($e->response->serverError() || $e->response->tooManyRequests());
    }

    private function logFailure(Response $response): void
    {
        Log::warning('Falha na API de cotações', [
            'status' => $response->status(),
            'body' => Str::limit($response->body(), 300),
        ]);
    }

    // toQuote(), que traduz o JSON para o DTO, aparece na próxima seção.
}

Cada detalhe desse cliente aparece nas próximas seções. Por enquanto, o importante é que tudo o que diz respeito ao fornecedor (URL, autenticação, tempos, tratamento de status) mora numa classe só. O registro no container liga a interface à implementação:

// app/Providers/AppServiceProvider.php
public function register(): void
{
    $this->app->bind(QuoteProvider::class, HttpQuoteProvider::class);
}

O controller ou o job recebe QuoteProvider por injeção de dependência e nunca toca no Http. Isso vai pesar muito a favor na hora dos testes.

Para integrações bem simples, o Laravel também permite registrar uma macro, como Http::macro('quotes', ...), que devolve um cliente com URL base e cabeçalhos já configurados. Resolve a repetição, mas não oferece o isolamento da interface.

DTO em vez de array solto

Devolver $response->json() para o resto da aplicação espalha a estrutura da API pelo projeto. Se o fornecedor renomear quoted_at para timestamp, o erro aparece em dez lugares diferentes.

A saída é um DTO (Data Transfer Object, ou objeto de transferência de dados). É uma classe que só carrega dados: propriedades tipadas e nada mais. Sem regra de negócio, sem acesso a banco, sem chamada HTTP, sem validação. Como ele representa um conceito da aplicação, e não do fornecedor, fica fora da pasta da integração, em app/DataTransferObjects:

<?php

namespace App\DataTransferObjects;

use Carbon\CarbonImmutable;

final readonly class Quote
{
    public function __construct(
        public string $symbol,
        public float $price,
        public CarbonImmutable $quotedAt,
    ) {}
}

O readonly garante que o objeto não muda depois de criado: quem recebe uma cotação não consegue alterar o preço no meio do caminho. Os tipos fazem o resto. Se alguém tentar montar um Quote com o preço em texto ou sem data, o PHP recusa na hora.

Repare que o DTO usa os nomes da aplicação (quotedAt), não os da API (quoted_at). Quem traduz um formato para o outro é o adapter, o HttpQuoteProvider. É ele que conhece o JSON do fornecedor, então é nele que ficam a validação e a montagem do objeto:

// app/Services/Quotes/HttpQuoteProvider.php

private function toQuote(array $data): Quote
{
    $validator = Validator::make($data, [
        'symbol' => ['required', 'string', 'max:20'],
        'price' => ['required', 'numeric', 'min:0'],
        'quoted_at' => ['required', 'date'],
    ]);

    if ($validator->fails()) {
        throw new QuoteProviderException('Resposta da API de cotações fora do formato esperado.');
    }

    return new Quote(
        symbol: $data['symbol'],
        price: (float) $data['price'],
        quotedAt: CarbonImmutable::parse($data['quoted_at']),
    );
}

Cada peça fica com uma responsabilidade. O DTO define o formato que a aplicação usa. O adapter sabe como o fornecedor responde e converte uma coisa na outra. Se a API renomear um campo, a correção acontece só no toQuote(). Se você trocar de fornecedor, o novo adapter monta o mesmo Quote a partir de outro JSON, e controllers, jobs e telas nem percebem.

A validação no adapter ainda tem uma segunda função, que aparece na seção de segurança: impedir que dado malformado vindo de fora entre no sistema.

Decorator para cache e fallback

O padrão Decorator embrulha uma implementação com outra que respeita o mesmo contrato e acrescenta comportamento. Cache, circuit breaker e fallback entram assim, sem encostar no HttpQuoteProvider. O exemplo completo está na seção sobre indisponibilidade.

Quando o número de integrações cresce, vale conhecer o Saloon, um pacote PHP que organiza cada API em uma classe de conector e cada endpoint em uma classe de requisição, com recursos prontos para testes e OAuth2. Para uma ou duas APIs, o cliente HTTP nativo do Laravel com a estrutura acima costuma bastar.

Timeout e retry

Por padrão, o cliente HTTP do Laravel espera até 30 segundos por uma resposta e até 10 segundos para abrir a conexão. Numa requisição web, 30 segundos é uma eternidade. O usuário desiste, o processo do PHP-FPM fica preso e, se a API estiver lenta para todo mundo, o servidor enche de processos parados esperando resposta.

Defina os dois tempos de forma explícita, com valores pensados para cada integração. O connectTimeout() limita a abertura da conexão e o timeout() limita a espera total pela resposta. Estourado o limite, o Laravel lança ConnectionException.

O retry trata falhas passageiras, como uma oscilação de rede ou um erro 503 durante o deploy do fornecedor. O método retry() recebe o número de tentativas, o intervalo entre elas (um número, uma closure ou uma lista com o intervalo de cada tentativa) e uma condição que decide se vale tentar de novo. Com throw: false, ele devolve a última resposta em vez de lançar exceção quando as tentativas acabam, e a decisão fica com o seu código.

Três cuidados fazem diferença:

  • Repita só o que pode dar certo na segunda vez. Falha de conexão, 429 (limite de requisições excedido) e erros 5xx são candidatos. Um 401, 404 ou 422 vai falhar igual em todas as tentativas, e repetir só gasta tempo.
  • Aumente o intervalo a cada tentativa (backoff exponencial). Repetir imediatamente contra um serviço sobrecarregado ajuda a mantê-lo sobrecarregado.
  • Faça a conta do pior caso. No exemplo, a lista [200, 500] gera três tentativas. Com timeout de 5 segundos, a chamada pode segurar a requisição por quase 16 segundos. Se essa conta não cabe numa requisição web, a chamada deveria estar numa fila.

Retry em POST pede idempotência

Repetir um GET é inofensivo. Repetir um POST que cria um pagamento pode cobrar o cliente duas vezes. O caso mais traiçoeiro é o timeout: a API pode ter processado a operação e só a resposta ter se perdido no caminho. Do seu lado, não há como saber.

A saída é a chave de idempotência. Muitas APIs aceitam um cabeçalho, geralmente Idempotency-Key, com um identificador único gerado pelo cliente. A Stripe, por exemplo, guarda o status e o corpo da primeira requisição feita com cada chave e devolve o mesmo resultado nas repetições, e sugere UUID v4 como formato da chave.

$response = $this->client()
    ->withHeaders(['Idempotency-Key' => $payment->idempotency_key])
    ->post('/payments', $payload);

Gere a chave uma vez e guarde junto do registro (o pedido, o pagamento). Assim a mesma chave é usada tanto no retry do cliente HTTP quanto numa reexecução do job inteiro.

Quando a API não oferece idempotência

Muita API brasileira de boleto, nota fiscal ou ERP não aceita chave de idempotência. Nesse caso a garantia contra duplicidade precisa ser construída do seu lado, e a regra muda: escrita nunca é repetida às cegas. Antes de reenviar, a aplicação pergunta ao fornecedor se a tentativa anterior chegou lá.

Para isso funcionar, três peças trabalham juntas: uma referência própria enviada em cada criação, um status local que registra em que ponto a operação parou e um job que consulta antes de reenviar.

A referência é um identificador gerado pela sua aplicação e mandado no payload. Quase toda API tem um campo para isso, com nomes como external_reference, external_id ou seu_numero. Ela é criada uma vez, junto com o registro local, e protegida por índice único no banco:

// migration
Schema::create('charges', function (Blueprint $table) {
    $table->id();
    $table->uuid('reference')->unique();
    $table->string('status')->default('pending'); // pending, sending, unknown, created
    $table->string('external_id')->nullable();
    $table->unsignedInteger('amount');
    $table->timestamps();
});
// app/Models/Charge.php
protected static function booted(): void
{
    static::creating(fn (Charge $charge) => $charge->reference ??= (string) Str::uuid());
}

No gateway, o POST sai sem retry(). Falha de conexão ou erro 5xx não significam que a cobrança deixou de ser criada, então viram uma exceção específica que diz exatamente isso: o resultado é desconhecido. Já a consulta por referência é um GET e pode usar retry à vontade.

<?php

namespace App\Services\Billing;

use App\Models\Charge;
use Illuminate\Http\Client\ConnectionException;

final class HttpChargeGateway implements ChargeGateway
{
    public function create(Charge $charge): RemoteCharge
    {
        try {
            // Sem retry: repetir este POST pode duplicar a cobrança.
            $response = $this->client()->post('/charges', [
                'external_reference' => $charge->reference,
                'amount' => $charge->amount,
            ]);
        } catch (ConnectionException $e) {
            throw new ChargeOutcomeUnknown('Sem resposta ao criar a cobrança.', previous: $e);
        }

        if ($response->serverError()) {
            throw new ChargeOutcomeUnknown("Fornecedor respondeu {$response->status()}.");
        }

        return $this->toRemoteCharge($response->throw()->json());
    }

    public function findByReference(string $reference): ?RemoteCharge
    {
        $response = $this->client()
            ->retry([200, 500])
            ->get('/charges', ['external_reference' => $reference]);

        $item = $response->json('data.0');

        return $item ? $this->toRemoteCharge($item) : null;
    }

    // client() e toRemoteCharge() seguem o mesmo padrão do HttpQuoteProvider.
}

O job junta as peças. O middleware WithoutOverlapping impede que duas execuções para a mesma cobrança rodem ao mesmo tempo. O status é gravado como sending antes da chamada, e é ele que permite à próxima tentativa saber que o envio anterior pode ter chegado ao fornecedor, mesmo que o worker tenha morrido no meio do caminho.

<?php

namespace App\Jobs;

use App\Models\Charge;
use App\Services\Billing\ChargeGateway;
use App\Services\Billing\ChargeOutcomeUnknown;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Attributes\Tries;
use Illuminate\Queue\Middleware\WithoutOverlapping;

#[Tries(5)]
final class CreateCharge implements ShouldQueue
{
    use Queueable;

    public function __construct(public Charge $charge) {}

    public function middleware(): array
    {
        return [(new WithoutOverlapping($this->charge->id))->releaseAfter(30)->expireAfter(120)];
    }

    public function handle(ChargeGateway $gateway): void
    {
        $charge = $this->charge->refresh();

        if ($charge->status === 'created') {
            return;
        }

        // Uma tentativa anterior pode ter chegado ao fornecedor: confere antes de reenviar.
        if (in_array($charge->status, ['sending', 'unknown'], true)) {
            $remote = $gateway->findByReference($charge->reference);

            if ($remote !== null) {
                $charge->update(['status' => 'created', 'external_id' => $remote->id]);

                return;
            }
        }

        $charge->update(['status' => 'sending']);

        try {
            $remote = $gateway->create($charge);
        } catch (ChargeOutcomeUnknown) {
            $charge->update(['status' => 'unknown']);
            $this->release(60); // volta para a fila e, na próxima vez, consulta primeiro

            return;
        }

        $charge->update(['status' => 'created', 'external_id' => $remote->id]);
    }

    public function failed(): void
    {
        // Esgotou as tentativas sem confirmar: alguém precisa olhar no painel do fornecedor.
        // Notification::route('mail', config('billing.alert_email'))->notify(new ChargeNeedsReview($this->charge));
    }
}

O fluxo fica assim. Se a chamada der timeout, a cobrança vai para unknown e o job volta para a fila em um minuto. Na nova tentativa, ele busca pela referência: se a cobrança existir no fornecedor, só atualiza o registro local; se não existir, aí sim reenvia. Nenhum caminho cria duas cobranças para o mesmo registro.

Vale um cuidado com a consistência do fornecedor. Algumas APIs levam alguns segundos para a cobrança recém-criada aparecer na busca, por isso o intervalo de 60 segundos antes de consultar, em vez de consultar na hora.

Se a API não tem nem busca por referência, as opções ficam mais limitadas. Dá para listar as operações de um período e comparar valor, cliente e data, ou, quando o risco de errar é alto (dinheiro, documento fiscal), parar no status unknown e deixar a decisão para uma pessoa, avisada pelo método failed(). Duplicar uma cobrança costuma sair bem mais caro do que esperar alguns minutos por uma conferência manual.

Tratamento de erros

Diferente do Guzzle puro, o cliente do Laravel não lança exceção quando recebe um status 4xx ou 5xx. Ele devolve a resposta e deixa a decisão com você, por meio de métodos como successful(), failed(), clientError() e serverError(), ou do throw() quando você prefere a exceção.

Isso dá flexibilidade, mas abre espaço para o erro mais comum em integração: esquecer de verificar o status e seguir com $response->json() de uma resposta de erro, gravando lixo no banco.

Uma boa prática é transformar as falhas do fornecedor em exceções do seu domínio, com nomes que digam o que aconteceu:

<?php

namespace App\Services\Quotes;

use RuntimeException;

// Requisição recusada ou resposta em formato inesperado.
class QuoteProviderException extends RuntimeException {}

// O serviço não respondeu ou respondeu com erro de servidor. Vale tentar mais tarde.
final class QuoteProviderUnavailable extends QuoteProviderException {}

Com isso, quem chama decide de forma clara. O controller mostra uma mensagem amigável quando o serviço está fora, o job volta para a fila e um erro de contrato (a API mudou o formato) vira alerta para o time, em vez de uma tela de erro 500 para o usuário.

Log útil sem vazar segredo

Registre o que ajuda a investigar: fornecedor, endpoint, status, tempo de resposta e o identificador da requisição, quando a API devolve um. Corte o corpo da resposta, como faz o Str::limit() do exemplo. O próprio Laravel limita a 120 caracteres a mensagem de RequestException quando ela é registrada, e permite ajustar isso com RequestException::truncateAt().

Nunca registre o token, o cabeçalho Authorization ou dados pessoais que vieram no payload. Log costuma ter controle de acesso mais frouxo que o banco e vive mais tempo do que deveria.

Para acompanhar todas as chamadas sem espalhar log pelo código, o cliente HTTP dispara os eventos RequestSending, ResponseReceived e ConnectionFailed. Um listener nesses eventos alimenta métricas de latência e de taxa de erro por fornecedor.

Quando a API cai: filas, circuit breaker e fallback

Timeout e retry resolvem falhas curtas. Para uma indisponibilidade de minutos ou horas, a estratégia muda: a pergunta deixa de ser como insistir e passa a ser como não depender da resposta agora.

Tire a chamada do caminho do usuário

Se o usuário não precisa da resposta na hora (sincronizar um cadastro com o CRM, emitir uma nota fiscal, enviar dados a um ERP), a chamada vai para uma fila. O usuário recebe confirmação imediata e a integração tenta de novo em segundo plano, sem ninguém olhando para uma tela carregando.

Os jobs do Laravel trazem o necessário: limite de tentativas, limite de tempo com retryUntil(), intervalos entre tentativas e o método failed() para avisar alguém quando as tentativas acabam.

Circuit breaker

O circuit breaker, popularizado por Michael Nygard no livro Release It! e descrito por Martin Fowler, funciona como o disjuntor de casa. Enquanto as chamadas dão certo, o circuito fica fechado. Quando as falhas passam de um limite, ele abre, e as chamadas seguintes falham na hora, sem nem tentar falar com o serviço. Depois de um intervalo, o circuito fica meio aberto e deixa passar uma chamada de teste: se ela der certo, o circuito fecha; se falhar, abre de novo.

O ganho é duplo. A aplicação para de gastar tempo e processos esperando o timeout de um serviço que já se sabe fora do ar, e o fornecedor, que pode estar tentando se recuperar, deixa de receber carga.

Em jobs, o Laravel entrega isso pronto no middleware ThrottlesExceptions. Depois de um número de exceções, ele adia as tentativas seguintes por um intervalo. O método by() faz vários jobs que usam o mesmo fornecedor compartilharem o mesmo contador, e o when() restringe quais exceções contam.

<?php

namespace App\Jobs;

use App\Services\Quotes\QuoteProvider;
use App\Services\Quotes\QuoteProviderUnavailable;
use DateTime;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Middleware\ThrottlesExceptions;
use Throwable;

final class RefreshQuote implements ShouldQueue
{
    use Queueable;

    public function __construct(public string $symbol) {}

    public function handle(QuoteProvider $quotes): void
    {
        $quotes->latest($this->symbol);
    }

    public function middleware(): array
    {
        return [
            (new ThrottlesExceptions(10, 5 * 60))
                ->by('quotes-api')
                ->when(fn (Throwable $e) => $e instanceof QuoteProviderUnavailable)
                ->backoff(1),
        ];
    }

    public function retryUntil(): DateTime
    {
        return now()->plus(minutes: 30);
    }
}

Na prática: depois de 10 falhas por indisponibilidade, as tentativas de qualquer job com a chave quotes-api ficam suspensas por 5 minutos. Antes de atingir o limite, cada falha espera 1 minuto para a próxima tentativa, e o job desiste de vez depois de 30 minutos. Erros que não são de indisponibilidade seguem o tratamento normal e não contam para abrir o circuito. Quem usa Redis tem a variante ThrottlesExceptionsWithRedis, mais eficiente.

Para chamadas síncronas, um circuit breaker simples cabe numa classe apoiada no cache:

<?php

namespace App\Support;

use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;

final class CircuitBreaker
{
    public function __construct(
        private string $service,
        private int $threshold = 5,
        private int $cooldownSeconds = 60,
    ) {}

    public function isOpen(): bool
    {
        return Cache::has("circuit:{$this->service}:open");
    }

    public function recordFailure(): void
    {
        $key = "circuit:{$this->service}:failures";

        Cache::add($key, 0, 300);

        if (Cache::increment($key) >= $this->threshold) {
            Cache::put("circuit:{$this->service}:open", true, $this->cooldownSeconds);
            Cache::forget($key);
            Log::warning("Circuito aberto para {$this->service}");
        }
    }

    public function recordSuccess(): void
    {
        Cache::forget("circuit:{$this->service}:failures");
    }
}

O estado meio aberto vem de graça: quando a chave open expira, a próxima chamada passa e funciona como teste. Com vários servidores, use um cache compartilhado como o Redis, para que todas as instâncias enxerguem o mesmo estado. E registre toda abertura de circuito, porque ela é um dos melhores alertas de problema no fornecedor. A versão acima é didática e não trata disputa entre processos; para tráfego alto, um pacote dedicado faz mais sentido.

Fallback: o que mostrar quando não há resposta

Circuito aberto significa que a chamada falha rápido, mas ainda falha. O que a aplicação faz nesse momento é decisão de negócio. Fowler dá dois exemplos: colocar uma autorização de cartão numa fila para processar depois, ou mostrar um dado antigo que ainda é bom o bastante.

Para leitura, o decorator resolve bem. Ele guarda a última resposta boa e a devolve quando o fornecedor está fora:

<?php

namespace App\Services\Quotes;

use App\DataTransferObjects\Quote;
use App\Support\CircuitBreaker;
use Illuminate\Support\Facades\Cache;

final class ResilientQuoteProvider implements QuoteProvider
{
    public function __construct(
        private QuoteProvider $inner,
        private CircuitBreaker $breaker,
    ) {}

    public function latest(string $symbol): Quote
    {
        $cacheKey = "quotes:last:{$symbol}";

        if ($this->breaker->isOpen()) {
            return Cache::get($cacheKey)
                ?? throw new QuoteProviderUnavailable('Circuito aberto e sem cotação em cache.');
        }

        try {
            $quote = $this->inner->latest($symbol);
        } catch (QuoteProviderUnavailable $e) {
            $this->breaker->recordFailure();

            return Cache::get($cacheKey) ?? throw $e;
        }

        $this->breaker->recordSuccess();
        Cache::forever($cacheKey, $quote);

        return $quote;
    }
}
// app/Providers/AppServiceProvider.php
$this->app->bind(QuoteProvider::class, fn ($app) => new ResilientQuoteProvider(
    $app->make(HttpQuoteProvider::class),
    new CircuitBreaker('quotes-api'),
));

Como o DTO carrega quotedAt, a tela pode avisar que a cotação é de horas atrás em vez de fingir que é atual. Repare que nenhum controller mudou: a resiliência entrou só na ligação do container.

Respeite o limite do fornecedor

Muita indisponibilidade é autoinfligida: a aplicação estoura o limite de requisições do plano e passa a receber 429. Se a API aceita 60 requisições por minuto, o middleware RateLimited segura o ritmo dos jobs antes que isso aconteça:

// AppServiceProvider::boot()
RateLimiter::for('quotes-api', fn () => Limit::perMinute(60));

// no job
public function middleware(): array
{
    return [new RateLimited('quotes-api')];
}

Cada job barrado pelo limitador volta para a fila e consome uma tentativa, então ajuste o número de tentativas ou use retryUntil().

Segurança

Em 2023 a OWASP incluiu no seu Top 10 de segurança de APIs uma categoria só para este assunto: o consumo inseguro de APIs (API10:2023). O ponto central é que desenvolvedores costumam confiar mais em dados vindos de APIs de terceiros do que em dados digitados pelo usuário, principalmente quando o fornecedor é uma empresa conhecida. As práticas abaixo partem dessa recomendação.

  • Só HTTPS. A OWASP aponta a comunicação sem criptografia como sinal de API vulnerável. Garanta que a base_url de produção usa https e nunca desligue a verificação de certificado (verify => false) para resolver um erro de SSL.
  • Valide a resposta como valida um formulário. É o papel do Validator no método toQuote() do adapter. Texto vindo da API vai para o banco pelo Eloquent, com bindings, nunca por SQL concatenado, e para a tela escapado pelo Blade. A OWASP descreve justamente um ataque em que o invasor grava um payload de SQL injection num serviço terceiro e espera a vítima puxar esse dado.
  • Não siga redirecionamento às cegas. Se uma API comprometida responder com redirect para outro domínio, o cliente repete a requisição, com os dados sensíveis, contra o servidor do atacante. Por isso o exemplo usa withOptions(['allow_redirects' => false]). Se o redirect for necessário, mantenha uma lista dos destinos permitidos.
  • Limite o que a resposta pode consumir. Timeout também é segurança: a OWASP lista a falta dele, e a falta de limite de recursos para processar respostas, entre os sinais de consumo inseguro. Desconfie de respostas gigantes e de paginação sem fim.
  • Segredo tem escopo e prazo. Use token com a menor permissão possível, um por ambiente, guardado em variável de ambiente ou num cofre de segredos, com rotação planejada. Se o fornecedor permite restringir o acesso por IP, restrinja.
  • Não monte URL com entrada do usuário. Se parte do endereço chamado vem de um formulário, você abre a porta para SSRF, o ataque em que o invasor faz o seu servidor requisitar endereços internos. Mantenha a URL base fixa em configuração e passe o valor do usuário como parâmetro codificado, como o rawurlencode() do exemplo.

Webhooks: confira a assinatura

Muitas integrações também funcionam no sentido contrário: o fornecedor chama uma rota sua para avisar que um pagamento foi aprovado ou que uma nota foi emitida. Essa rota é pública, então qualquer um pode chamá-la. A defesa padrão é a assinatura HMAC: o fornecedor calcula um hash do corpo da requisição com um segredo compartilhado e envia no cabeçalho, e você recalcula e compara.

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

final class VerifyQuotesWebhook
{
    public function handle(Request $request, Closure $next): Response
    {
        $expected = hash_hmac(
            'sha256',
            $request->getContent(),
            config('services.quotes.webhook_secret'),
        );

        if (! hash_equals($expected, (string) $request->header('X-Signature'))) {
            abort(401);
        }

        return $next($request);
    }
}

Alguns detalhes fazem a verificação funcionar de verdade. Use o corpo bruto, com getContent(), porque qualquer reformatação do JSON muda o hash; a documentação da Stripe dedica uma página inteira a falhas causadas por frameworks que alteram o corpo antes da verificação. Compare com hash_equals(), que leva o mesmo tempo para qualquer entrada e não dá pistas a quem tenta adivinhar a assinatura. Se o fornecedor envia um timestamp assinado, rejeite mensagens antigas para impedir o reenvio de uma requisição capturada.

O nome do cabeçalho e o formato exato da assinatura variam por fornecedor, então siga a documentação de cada um e prefira o SDK oficial quando ele já faz a verificação. Por fim, fornecedores reenviam webhooks quando não recebem resposta a tempo: registre o ID de cada evento processado e ignore os repetidos, e responda rápido, deixando o processamento pesado para uma fila.

Testes automatizados

Teste que chama a API real é lento, instável, pode custar dinheiro e quebra quando o sandbox do fornecedor sai do ar. O Laravel resolve a maior parte disso com o Http::fake(), que intercepta as requisições e devolve as respostas definidas no próprio teste.

Comece travando a suíte contra chamadas reais. Com Http::preventStrayRequests(), qualquer requisição sem resposta falsa correspondente lança exceção em vez de sair para a internet. No setUp do TestCase base, nenhum teste esquecido vai bater no fornecedor:

// tests/TestCase.php
protected function setUp(): void
{
    parent::setUp();

    Http::preventStrayRequests();
}

Agora os cenários que importam, escritos com Pest. Primeiro o caminho feliz, conferindo também o que foi enviado:

use App\Services\Quotes\HttpQuoteProvider;
use App\Services\Quotes\QuoteProviderException;
use App\Services\Quotes\QuoteProviderUnavailable;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;

beforeEach(function () {
    config([
        'services.quotes.base_url' => 'https://quotes.test',
        'services.quotes.token' => 'test-token',
    ]);
});

it('returns the latest quote', function () {
    Http::fake([
        'quotes.test/*' => Http::response([
            'symbol' => 'KC',
            'price' => 3.85,
            'quoted_at' => '2026-10-08T10:00:00Z',
        ]),
    ]);

    $quote = app(HttpQuoteProvider::class)->latest('KC');

    expect($quote->price)->toBe(3.85);

    Http::assertSent(fn (Request $request) =>
        $request->url() === 'https://quotes.test/quotes/KC'
        && $request->hasHeader('Authorization', 'Bearer test-token'));
});

O retry, com uma sequência de respostas que simula a API falhando duas vezes antes de responder:

it('retries on server errors', function () {
    Http::fake([
        'quotes.test/*' => Http::sequence()
            ->pushStatus(503)
            ->pushStatus(503)
            ->push(['symbol' => 'KC', 'price' => 3.85, 'quoted_at' => '2026-10-08T10:00:00Z']),
    ]);

    app(HttpQuoteProvider::class)->latest('KC');

    Http::assertSentCount(3);
});

A indisponibilidade, com Http::failedConnection() simulando timeout ou falha de rede, e a mudança de contrato, que é o erro que normalmente só aparece em produção:

it('signals unavailability when the connection fails', function () {
    Http::fake(['quotes.test/*' => Http::failedConnection()]);

    expect(fn () => app(HttpQuoteProvider::class)->latest('KC'))
        ->toThrow(QuoteProviderUnavailable::class);
});

it('rejects a payload in an unexpected format', function () {
    Http::fake(['quotes.test/*' => Http::response(['valor' => 3.85])]);

    expect(fn () => app(HttpQuoteProvider::class)->latest('KC'))
        ->toThrow(QuoteProviderException::class);
});

Os intervalos do retry também rodam nos testes. Se a suíte ficar lenta, leve a lista de intervalos para a configuração e use zero no ambiente de testes.

Para testar controllers e jobs, nem é preciso falar de HTTP. Como eles dependem da interface, basta trocar a implementação no container por uma versão falsa:

final class FakeQuoteProvider implements QuoteProvider
{
    public function latest(string $symbol): Quote
    {
        return new Quote($symbol, 3.85, CarbonImmutable::parse('2026-10-08 10:00'));
    }
}

// no teste
$this->app->instance(QuoteProvider::class, new FakeQuoteProvider);

O decorator de fallback se testa do mesmo jeito: um fake que lança QuoteProviderUnavailable, o cache preenchido ou vazio, e a verificação do que o ResilientQuoteProvider devolve em cada caso.

Testes com fake têm um ponto cego: provam que o seu código lida com a resposta que você imagina, não com a que a API devolve hoje. Um conjunto pequeno de testes de contrato contra o sandbox do fornecedor, num grupo separado que roda fora do pipeline principal (uma vez por dia, por exemplo), avisa quando o formato real mudou:

it('matches the sandbox contract', function () {
    Http::allowStrayRequests(['sandbox.cotacoes.example/*']);

    $quote = app(HttpQuoteProvider::class)->latest('KC');

    expect($quote->price)->toBeFloat();
})->group('contract');

// no pipeline principal: php artisan test --exclude-group=contract

Checklist

  • URL, token e tempos limite em config/services.php, lidos com config().
  • Uma interface no domínio, uma classe por fornecedor que valida e converte a resposta, e um DTO simples para circular na aplicação.
  • connectTimeout() e timeout() explícitos em toda chamada.
  • Retry só para erro transitório, com intervalo crescente entre tentativas.
  • Retry em operação de escrita só com chave de idempotência. Sem ela, referência própria, status local e consulta antes de reenviar.
  • Falhas convertidas em exceções do domínio e log sem token ou dado pessoal.
  • Chamadas que podem esperar dentro de jobs, não da requisição web.
  • Circuit breaker e fallback para as leituras críticas.
  • Limite de requisições do fornecedor respeitado com RateLimited.
  • HTTPS, resposta validada e redirecionamento desligado.
  • Webhook com assinatura verificada e eventos deduplicados.
  • Http::preventStrayRequests() na suíte e testes de sucesso, retry, falha de conexão e formato inválido.
  • Testes de contrato contra o sandbox, separados do pipeline principal.

Referências

Comentários (0)

Nenhum comentário ainda. Seja o primeiro.

Continue lendo

Instalar o PGAdmin 4 em distribuições Linux Debian 12

Instalar o PGAdmin 4 em distribuições Linux Debian 12

Recentemente enquanto utilizava o Linux Mint 21 Vera tive uma série de problemas ao instalar o PGAdmin 4 e numa conversa com um colega de trabalho surgiu a oportunidade de vir a utilizar o Debian. A recomendação inicial…

Linux, Postgres, Tutorial
Como instalar o Go Language no Ubuntu ?

Como instalar o Go Language no Ubuntu ?

Então resolvi tirar um tempo para conhecer outras linguagens de programação e no momento resolvi iniciar com a GO pois foge a uma série de padrões de programação na qual já estou acostumado e as etapas desse processo de…

Artigo, GO, Tutorial