Skip to content

perf(embeds): remove blocking oEmbed calls from request path - #8

Open
Guajir0-code wants to merge 2 commits into
sourcevortex:mainfrom
Guajir0-code:perf/remove-blocking-oembed
Open

perf(embeds): remove blocking oEmbed calls from request path#8
Guajir0-code wants to merge 2 commits into
sourcevortex:mainfrom
Guajir0-code:perf/remove-blocking-oembed

Conversation

@Guajir0-code

Copy link
Copy Markdown

Problema

Renderizar um post dispara até três requisições HTTP síncronas para serviços externos, dentro do ciclo da requisição, cada uma com timeout de 5 segundos. E, quando falham, são repetidas em toda requisição, para sempre.

Causa

EmbedProcessorService consultava o oEmbed de Twitter, Instagram e Reddit ao processar o conteúdo:

$response = Http::timeout(5)->get('https://publish.twitter.com/oembed', [...]);

Três problemas se somam.

1. Bloqueia o TTFB

As chamadas acontecem enquanto o Laravel monta a resposta. Um post com os três tipos de embed pode somar 15 segundos ao tempo de resposta se os provedores estiverem lentos — e o visitante espera por isso.

2. O cache negativo não funciona

return Cache::remember($cacheKey, 86400, function () use ($url) {
    try {
        // ...
    } catch (\Exception $e) {
        Log::warning(...);
    }
    return null;    // <-
});

Cache::remember() trata null como ausência de valor. Como todos esses helpers retornam null em falha, nada é gravado e a chamada é refeita na requisição seguinte. Não há backoff nem circuit breaker: um provedor fora do ar custa uma tentativa por pageview, indefinidamente.

3. O endpoint do Instagram não existe mais

Http::timeout(5)->get('https://api.instagram.com/oembed', [...]);

A Meta desativou esse endpoint em 2020; hoje o oEmbed do Instagram exige a Graph API com token. Esse caminho falha 100% das vezes — e, pelo item 2, é retentado 100% das vezes.

Solução

Remover as consultas oEmbed e sempre emitir a marcação que os scripts dos provedores esperam.

Isso não é uma degradação: é o caminho que o código já usava sempre que uma requisição falhava. O fallback existente já produzia exatamente essa marcação, e PostContentText.vue já carrega os três scripts que a transformam no embed final:

if (props.postContent.includes('embed-twitter') || ...) loadTwitterWidget();
if (props.postContent.includes('embed-instagram') || ...) loadInstagramEmbed();
if (props.postContent.includes('embed-reddit') || ...) loadRedditEmbed();

platform.twitter.com/widgets.js, instagram.com/embed.js e embed.reddit.com/widgets.js renderizam a partir de <blockquote class="twitter-tweet">, <blockquote class="instagram-media"> e <blockquote class="reddit-embed-bq"> respectivamente. É o mecanismo oficial de embed dos três.

O YouTube sempre funcionou assim — regex direto para <iframe>, sem nenhuma chamada — e serve de prova de que o padrão se sustenta.

Com as chamadas fora, Http, Cache e Log deixam de ser necessários neste serviço.

Como validar

php vendor/bin/pest --filter=EmbedProcessorTest
Tests: 7 passed (27 assertions)

Cada teste roda com Http::fake() e um afterEach com Http::assertNothingSent().

Verificado nos dois sentidos. Contra o código anterior, 5 dos 7 falham por requisições registradas.

Uma nota sobre como esse teste foi escrito

A primeira versão usava Http::preventStrayRequests() e passava contra o código com o problema. O motivo: preventStrayRequests() lança exceção, e o código antigo envolvia a chamada num try/catch (\Exception $e) — a exceção era engolida, o fallback entrava, e as asserções sobre a marcação passavam normalmente.

Http::fake() grava a requisição em vez de lançar, então a asserção sobrevive ao try/catch. Vale o registro porque o mesmo erro é fácil de repetir em qualquer teste de código defensivo.

Impacto

  • Resultado visual: o mesmo. Os embeds continuam renderizando, pelos scripts dos provedores.
  • TTFB: deixa de depender de três serviços externos.
  • Privacidade: inalterada. Os scripts de terceiros já eram carregados no cliente.
  • Schema e configuração: nada muda.
  • Rollback: reverter o commit.

O que se perde

O HTML do oEmbed traz o conteúdo do post já embutido (texto do tweet, contagens), o que dá um render inicial um pouco mais rico quando o serviço responde. Na prática, isso valia para o Twitter e o Reddit, e nunca para o Instagram. Achei uma troca favorável: o ganho aparecia apenas quando o provedor estava rápido, e o custo aparecia sempre.

Se o embed rico do lado do servidor for considerado necessário, o caminho correto é pré-processar fora da requisição — um comando agendado que aquece um cache de HTML já pronto — e não voltar a chamar durante o render.

Fora de escopo

  • Pré-processamento assíncrono do conteúdo. Descrito acima como alternativa; é um PR próprio.
  • Content Security Policy. Os scripts de terceiros continuam sem allowlist.
  • Os regex de detecção de bloco não foram alterados, para manter o diff restrito ao que causa as chamadas de rede.

Guajir0-code and others added 2 commits August 5, 2026 10:10
The app points Eloquent at an existing WordPress database, so no migration
creates wp_posts, wp_users, wp_terms and friends. Any test that touched the
database therefore could not run, and the single feature test in the repo
(GET / asserting 200) failed on a missing database.

Add a schema builder for the wp_* tables the app reads, small row builders,
and smoke coverage for all seven routes in routes/web.php, including the
highlighted-post exclusion, search, and the published/scheduled filtering the
global scope is responsible for.

withoutVite() keeps the suite independent of `npm run build`.
ExampleTest is dropped: RoutesTest covers GET / properly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Rendering a post issued up to three synchronous HTTP requests, one per embed
provider, each with a 5s timeout, inside the request cycle.

They were also effectively uncached on failure: Cache::remember() treats null
as a miss, and every one of these helpers returned null when the provider did
not answer, so a failing embed was retried on every single request forever.
api.instagram.com/oembed in particular has been dead since Meta retired it in
2020, so that path failed 100% of the time.

Drop the oEmbed lookups and always emit the markup the provider scripts expect.
PostContentText.vue already loads platform.twitter.com/widgets.js,
instagram.com/embed.js and embed.reddit.com/widgets.js, which turn that markup
into the real embed on the client. This is the same path the old fallback used
whenever a request failed, and it is how the YouTube handler has always worked.

Net effect: three fewer external dependencies in TTFB, no unbounded retry, and
Http, Cache and Log are no longer needed by this service.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant