mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
fix(C220-FOLLOWUP): early-stop da janela de link do Instagram na Central
O resolver pedia a janela de 500 (ate 10 paginas de cursor) antes de casar o shortcode; erro em pagina >=2 virava 'indisponivel' mesmo com o post na pagina 1. O dono da paginacao ganha shouldStopAt (board/sync intocados) e o resolver para na pagina que contem o post — a colagem tipica volta a custar uma chamada.
This commit is contained in:
parent
767f86c455
commit
b5c4e274a2
6 changed files with 242 additions and 9 deletions
|
|
@ -0,0 +1 @@
|
|||
**Instagram por link na Central volta a baixar e catalogar — early-stop da janela de 500 (2026-09-24):** o C220 prometia "janela de 500 mídias com early-stop; a chamada típica é 1", mas a implementação pedia as até 10 páginas de cursor ANTES de casar o shortcode: qualquer erro em página ≥2 (a doc oficial da edge `/{ig-user-id}/media` documenta paginação time-based) lançava e o resolver devolvia `indisponivel`, mesmo com o post recém-publicado já na página 1 — a peça-link voltava sem mídia/transcrição. O dono da paginação (`src/utilities/socialFeed/instagramFeed.ts`) ganha o opcional `shouldStopAt?: (post) => boolean` (checado após cada página; board/sync não passam — contrato de 1 página e tudo-ou-nada intocados) e o resolver (`src/utilities/content/contentPieceLink.ts`) passa `matchesInstagramShortcode(link.shortcode)`, reusado no match final: a colagem típica volta a custar UMA chamada de mídia e falha profunda nunca mais derruba um match de página 1. Sem migration/access/UI. Verificação: int de regressão com o `loadInstagramFeed` REAL e `fetchImpl` fake (`after=` → 400) — RED provado sem o fix (`indisponivel`) e verde com; unit do dono (para no match, continua quando o alvo está mais fundo); int contentPiece 38/38; `pnpm gate:fast` verde (unit full 4669). Post-mortem: `docs/postmortems/2026-09-24-instagram-link-sem-early-stop.md`.
|
||||
80
docs/postmortems/2026-09-24-instagram-link-sem-early-stop.md
Normal file
80
docs/postmortems/2026-09-24-instagram-link-sem-early-stop.md
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
# Post-mortem: Link do Instagram na Central não baixava a peça — janela de 500 sem early-stop
|
||||
|
||||
> Template do `/bug-fix`. Preencha com fatos apurados; o que não for apurado fica "não apurado" — nunca invente.
|
||||
|
||||
## Registro
|
||||
|
||||
| Campo | Valor |
|
||||
| ------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Data do post-mortem | 2026-09-24 |
|
||||
| Severidade | alta (fluxo editorial de produção prometido não funcionava; sem perda de dados) |
|
||||
| Ambiente | prod (sintoma) + dev/worktree (diagnóstico e fix) |
|
||||
| Issue(s) | sem Issue — o item anterior, C220 (#1302), já estava fechado/in-prod; o residual não gerou Issue (o post-mortem é o registro) |
|
||||
| PR do fix | a preencher |
|
||||
| Detectado por | humano (relato do usuário na sessão `/bug-fix` do worktree `fix/10`, 2026-09-24 ~22:09 BRT) |
|
||||
|
||||
## Timeline
|
||||
|
||||
| Momento | Data/hora | Evento |
|
||||
| ------------------ | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Início provável | 2026-09-24 ~03:19 BRT | Commit `8f311831` "fix(C220): link do proprio Instagram baixa e cataloga a peca" (PR #1314) introduz a janela de 500 sem early-stop |
|
||||
| C220 em produção | 2026-09-24 12:56–13:07 UTC | Deploy run 35966535233 (SHA `8f311831`), job "deploy production" |
|
||||
| Detecção | 2026-09-24 ~22:09 BRT | Relato do humano na sessão `/bug-fix` |
|
||||
| Correção mergeada | pendente | Em andamento neste PR (a preencher número) |
|
||||
| Deploy | pendente | Merge em `main` dispara deploy; staging automático; produção só com approve humano no environment `production` (não feito ainda) |
|
||||
| Verificado em prod | pendente | Aguardando confirmação do humano (nunca declarar corrigido em prod antes disso) |
|
||||
|
||||
## O bug
|
||||
|
||||
Na Central de Conteúdos (`/campanha/comunicacao/conteudos`), colar o link de uma publicação do próprio Instagram do deputado (@depjorgesolla) não baixava a mídia nem transcrevia/catalogava automaticamente: a peça ficava como peça-link. Afetava a assessoria de comunicação no fluxo editorial que o C220 prometia entregar.
|
||||
|
||||
## Causa-raiz
|
||||
|
||||
O design aprovado do C220 (`docs/plans/central-conteudos-link-instagram-impl.md`, D2 e §Riscos) prometia "janela de 500 mídias … early-stop no match; a chamada típica é 1" e que uma paginação time-based degradaria para a 1ª página sem quebra. A implementação não honrou o early-stop:
|
||||
|
||||
1. O resolver chamava `loadInstagramFeed` com `maxResults=500` (`src/utilities/content/contentPieceLink.ts:178` pré-fix).
|
||||
2. O match do shortcode só acontecia DEPOIS de andar a janela inteira — até 10 páginas via `paging.cursors.after` (`src/utilities/socialFeed/instagramFeed.ts:263-281`).
|
||||
3. O contrato era tudo-ou-nada: erro em página ≥2 lançava (`instagramFeed.ts:273-275`) e o resolver capturava QUALQUER erro do feed como `indisponivel` (`contentPieceLink.ts:182-186`), descartando a página 1 já recebida.
|
||||
|
||||
Resultado: o caso típico (post recém-publicado, na página 1) ficava refém de páginas profundas. A doc oficial da edge `/{ig-user-id}/media` no host Instagram Login documenta paginação TIME-BASED (`since`/`until`), e o próprio C212 registrou a paginação por cursor como "a confirmar na implementação".
|
||||
|
||||
**Evidência de reprodução:** script com o `loadInstagramFeed` real e `fetchImpl` fake (página 1 com o alvo + `paging.cursors.after`; `after=` → HTTP 400); pré-fix lança `InstagramApiError` e o resolver devolve `linkFailureReason='indisponivel'` mesmo com o post na página 1. Coberto pelo teste int novo (`tests/int/contentPiece.int.spec.ts`, "stops at page 1 when the pasted post is there, even if deeper cursors fail") — vermelho sem o fix (`expected 'indisponivel' to be null`), verde com.
|
||||
|
||||
**Por que os testes não pegaram:** o unit do feed usava cursor fabricado; os testes int do resolver injetavam `loadFeed` fake (nunca o dono real); o e2e do motivo gravava `linkFailureReason` direto no banco e não esperava o job. Nenhum teste exercia o `loadInstagramFeed` real através do resolver.
|
||||
|
||||
**Observação honesta:** a API real com a credencial de produção não foi acessada (proibido); a forma exata da paginação da edge segue **não apurada**. A hipótese H2 (API omitir `media_url` em Reels com áudio licenciado, documentada no C212) permanece **não apurada** e pode afetar reels específicos — nesse caso o motivo honesto `indisponivel` continua correto, mas não há via oficial de baixar o arquivo.
|
||||
|
||||
## Correção
|
||||
|
||||
`src/utilities/socialFeed/instagramFeed.ts` ganhou o opcional `shouldStopAt?: (post) => boolean` em `LoadInstagramFeedArgs`, checado após cada página; board/sync não passam a opção, então o contrato de 1 página e o tudo-ou-nada ficam intocados. `src/utilities/content/contentPieceLink.ts` passa `shouldStopAt: matchesInstagramShortcode(link.shortcode)` (novo helper, reusado no match final).
|
||||
|
||||
Efeito: a colagem típica volta a custar UMA chamada de mídia, e uma falha em página profunda nunca mais transforma um match de página 1 em `indisponivel`. Sem migration, sem mudança de access/UI.
|
||||
|
||||
## Verificação
|
||||
|
||||
- Teste de regressão: `tests/int/contentPiece.int.spec.ts` — "stops at page 1 when the pasted post is there, even if deeper cursors fail" — falha sem o fix com `indisponivel`, passa com
|
||||
- Unit novo: `tests/unit/instagramFeed.unit.spec.ts` — "stops at the page that holds the searched post…" e "keeps walking when the searched post is deeper" — 23/23
|
||||
- Suíte: int contentPiece 38/38; `pnpm gate:fast` verde (lint/typecheck/unit full 4669; houve 1 flake de `contentPiecePeopleField.unit.spec.tsx` sob carga, que passou 2x em isolamento)
|
||||
- CI: pendente no PR
|
||||
- Prod: pendente da confirmação do humano
|
||||
|
||||
## Prevenção
|
||||
|
||||
| Estratégia | Custo | Estado |
|
||||
| ---------------------------------------------------------------------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------- |
|
||||
| Teste de regressão no nível certo (int do resolver com o `loadInstagramFeed` REAL + `fetchImpl` fake, pinando 1 chamada e zero `after=`) | barata | implementada agora (mesmo PR) |
|
||||
| Unit do dono: para no match e continua quando o alvo está mais fundo | barata | implementada agora (mesmo PR) |
|
||||
| Best-effort no modo lookup (página ≥2 falha → devolver o já coletado em vez de `indisponivel`) | cara | documentada — distorce o vocabulário (`nao-encontrado` falso para post fundo); só com evidência de colagens antigas |
|
||||
| Verificação viva/canário com credencial real (staging/prod) + runbook do token | cara | documentada — única verificação não-cega da forma da paginação na edge |
|
||||
| Pesquisar/decidir o contrato time-based da edge (`since`/`until` × cursor `after` × `paging.next` sem vazar token) | cara | documentada — candidata a Issue |
|
||||
|
||||
**Estratégia implementada:** os três testes acima (unit do dono + int do resolver com o feed real).
|
||||
|
||||
**Estratégia documentada (cara):** as três acima.
|
||||
|
||||
## Lições
|
||||
|
||||
- Promessa de plano sem teste executável vira comentário: "early-stop em 1 chamada" era contrato do design e não tinha prova.
|
||||
- O seam de injeção de dependência pode virar blindagem: testar o dublê e nunca o dono real esconde regressões no caminho de produção.
|
||||
- "A confirmar na implementação" precisa de verificação viva antes de vender o comportamento como entregue.
|
||||
- Degradação honesta prometida no plano precisa de teste no modo real de falha (página ≥2), não só no caminho feliz.
|
||||
|
|
@ -65,6 +65,19 @@ type FeedLoader = typeof loadInstagramFeed
|
|||
*/
|
||||
export const CONTENT_PIECE_LINK_INSTAGRAM_WINDOW = 500
|
||||
|
||||
/**
|
||||
* The identity of a Graph API post for the pasted link: the shortcode (the
|
||||
* kind is presentation, so a `/p/ABC/` paste matches a reel's permalink too).
|
||||
* The same predicate drives the pagination early stop and the final match, so
|
||||
* the walk never continues past the answer.
|
||||
*/
|
||||
const matchesInstagramShortcode =
|
||||
(shortcode: string) =>
|
||||
(post: { permalink: string }): boolean => {
|
||||
const parsed = parseContentPieceLink(post.permalink)
|
||||
return parsed?.origin === 'instagram' && parsed.shortcode === shortcode
|
||||
}
|
||||
|
||||
/** A hung Graph API must not hold the piece `processando` until the 1 h reaper. */
|
||||
const CONTENT_PIECE_LINK_FEED_TIMEOUT_MS = 30_000
|
||||
|
||||
|
|
@ -176,6 +189,10 @@ export const resolveContentPieceSource = async ({
|
|||
accessToken: settings.instagramAccessToken as string,
|
||||
userId: settings.instagramUserId as string,
|
||||
maxResults: CONTENT_PIECE_LINK_INSTAGRAM_WINDOW,
|
||||
// Early stop: the page that carries the pasted post ends the walk, so the
|
||||
// typical paste costs ONE call and a failing deeper cursor can never turn
|
||||
// a page-1 match into `indisponivel`.
|
||||
shouldStopAt: matchesInstagramShortcode(link.shortcode),
|
||||
fetchImpl,
|
||||
signal: AbortSignal.timeout(CONTENT_PIECE_LINK_FEED_TIMEOUT_MS),
|
||||
})
|
||||
|
|
@ -194,10 +211,7 @@ export const resolveContentPieceSource = async ({
|
|||
// The shortcode is the identity; the kind (`p` × `reel`) is presentation, so
|
||||
// a `/p/ABC/` paste matches a reel's permalink too. Matching inside the OWN
|
||||
// profile feed is also what guarantees a third-party link never downloads.
|
||||
const matched = feed.posts.find((post) => {
|
||||
const parsed = parseContentPieceLink(post.permalink)
|
||||
return parsed?.origin === 'instagram' && parsed.shortcode === link.shortcode
|
||||
})
|
||||
const matched = feed.posts.find(matchesInstagramShortcode(link.shortcode))
|
||||
if (!matched) {
|
||||
return linkOnly('nao-encontrado')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,15 @@ export type LoadInstagramFeedArgs = {
|
|||
accessToken: string
|
||||
userId: string
|
||||
maxResults: number
|
||||
/**
|
||||
* Lookup opt-in (C220): stop walking cursor pages as soon as this returns
|
||||
* true for a post of the page just fetched. The caller still matches the post
|
||||
* it wants in the returned feed — this only bounds the walk, so the typical
|
||||
* pasted-link search costs ONE call and never depends on deeper pages. The
|
||||
* board/sync omit it: their all-or-nothing contract and one-page default stay
|
||||
* untouched.
|
||||
*/
|
||||
shouldStopAt?: (post: InstagramPost) => boolean
|
||||
fetchImpl?: (input: string, init?: RequestInit) => Promise<Response>
|
||||
baseUrl?: string
|
||||
/** Deadline for the Graph API calls (hook-triggered sync must not hang a save). */
|
||||
|
|
@ -234,16 +243,18 @@ const jsonFrom = async (response: Response): Promise<unknown> => response.json()
|
|||
* of posts: up to `INSTAGRAM_MAX_RESULTS_CAP` per page, walking the
|
||||
* `paging.cursors.after` cursor while the window is not filled — so a caller
|
||||
* asking for 50 makes exactly one call (the board's contract is untouched) and
|
||||
* a deeper window is an explicit opt-in. On any failure it attempts one token
|
||||
* refresh (`refresh_access_token` — only mints/refreshes Instagram Login
|
||||
* tokens; page tokens from Facebook Login error out and the caller falls back
|
||||
* to the snapshot) and retries once. Throws when the retry also fails so the
|
||||
* cached wrapper can fail closed.
|
||||
* a deeper window is an explicit opt-in. The lookup opt-in (`shouldStopAt`)
|
||||
* bounds the walk at the page that carries the wanted post. On any failure it
|
||||
* attempts one token refresh (`refresh_access_token` — only mints/refreshes
|
||||
* Instagram Login tokens; page tokens from Facebook Login error out and the
|
||||
* caller falls back to the snapshot) and retries once. Throws when the retry
|
||||
* also fails so the cached wrapper can fail closed.
|
||||
*/
|
||||
export const loadInstagramFeed = async ({
|
||||
accessToken,
|
||||
userId,
|
||||
maxResults,
|
||||
shouldStopAt,
|
||||
fetchImpl = fetch,
|
||||
baseUrl = INSTAGRAM_API_BASE_URL,
|
||||
signal,
|
||||
|
|
@ -277,6 +288,9 @@ export const loadInstagramFeed = async ({
|
|||
const parsed = parseInstagramMediaPage(await jsonFrom(mediaResponse))
|
||||
posts.push(...parsed.posts)
|
||||
if (posts.length >= maxResults || !parsed.nextCursor) break
|
||||
// Lookup mode: the wanted post already arrived; deeper pages are never
|
||||
// requested (a failing cursor past the answer cannot poison the result).
|
||||
if (shouldStopAt && parsed.posts.some(shouldStopAt)) break
|
||||
after = parsed.nextCursor
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -981,6 +981,74 @@ describe('content pieces (C211)', () => {
|
|||
expect(row.media).toBe(resolution.media?.id)
|
||||
})
|
||||
|
||||
it('stops at page 1 when the pasted post is there, even if deeper cursors fail', async () => {
|
||||
await setInstagramSettings(true)
|
||||
const { piece } = await createPiece({
|
||||
title: 'Reel recém-publicado',
|
||||
origin: 'instagram',
|
||||
withMedia: false,
|
||||
sourceUrl: 'https://www.instagram.com/reel/RECENTE1/',
|
||||
})
|
||||
const calls: string[] = []
|
||||
|
||||
const resolution = await withTempDir((tempDir) =>
|
||||
resolveContentPieceSource({
|
||||
payload,
|
||||
piece: pieceInput(piece),
|
||||
tempDir,
|
||||
// No injected `loadFeed`: the real pagination owner is under test.
|
||||
fetchImpl: async (input) => {
|
||||
calls.push(input)
|
||||
if (input.includes('cdn.example')) {
|
||||
return new Response(Buffer.from('reel-bytes'), { status: 200 })
|
||||
}
|
||||
if (input.includes('/media')) {
|
||||
// The deeper cursor is broken on this edge (C212); the page-1
|
||||
// match must never depend on asking it.
|
||||
if (input.includes('after=')) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: { message: 'cursor não suportado' } }),
|
||||
{
|
||||
status: 400,
|
||||
},
|
||||
)
|
||||
}
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: [
|
||||
{
|
||||
id: 'media-1',
|
||||
caption: 'Legenda oficial',
|
||||
media_type: 'REEL',
|
||||
media_url: 'https://cdn.example/reel.mp4',
|
||||
permalink: 'https://www.instagram.com/reel/RECENTE1/',
|
||||
timestamp: '2026-09-01T10:00:00+00:00',
|
||||
},
|
||||
],
|
||||
paging: { cursors: { after: 'CURSOR-1' } },
|
||||
}),
|
||||
{ status: 200 },
|
||||
)
|
||||
}
|
||||
if (input.includes('/refresh_access_token')) {
|
||||
return new Response(JSON.stringify({ access_token: 'refreshed-token' }), {
|
||||
status: 200,
|
||||
})
|
||||
}
|
||||
return new Response(JSON.stringify({ username: 'depjorgesolla' }), { status: 200 })
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(resolution.linkFailureReason).toBeNull()
|
||||
expect(resolution.media).toBeTruthy()
|
||||
if (resolution.media) createdMediaIds.add(resolution.media.id)
|
||||
// The typical paste costs one media call; the broken deeper cursor is
|
||||
// never requested (a page-1 match cannot become `indisponivel`).
|
||||
expect(calls.filter((call) => call.includes('/media'))).toHaveLength(1)
|
||||
expect(calls.some((call) => call.includes('after='))).toBe(false)
|
||||
})
|
||||
|
||||
it('reports nao-encontrado when the post is not in the window', async () => {
|
||||
await setInstagramSettings(true)
|
||||
const { piece } = await createPiece({
|
||||
|
|
|
|||
|
|
@ -339,6 +339,62 @@ describe('loadInstagramFeed', () => {
|
|||
expect(mediaCalls[1]).toContain('limit=3')
|
||||
})
|
||||
|
||||
it('stops at the page that holds the searched post and never asks a deeper cursor', async () => {
|
||||
calls.length = 0
|
||||
const result = await loadInstagramFeed({
|
||||
...args,
|
||||
maxResults: 500,
|
||||
shouldStopAt: (post) => post.id === 'post2',
|
||||
fetchImpl: async (input) => {
|
||||
calls.push(input)
|
||||
if (input.includes('/media')) {
|
||||
// A deeper page exists and would fail loudly: the lookup must never ask.
|
||||
if (input.includes('after=')) throw new Error('deeper page must not be requested')
|
||||
return fakeResponse({
|
||||
data: [MEDIA_ITEM('post1'), MEDIA_ITEM('post2')],
|
||||
paging: { cursors: { after: 'cursor-1' } },
|
||||
})
|
||||
}
|
||||
return fakeResponse({ username: 'depjorgesolla' })
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.posts.map((post) => post.id)).toEqual(['post1', 'post2'])
|
||||
const mediaCalls = calls.filter((call) => call.includes('/media'))
|
||||
expect(mediaCalls).toHaveLength(1)
|
||||
expect(calls.some((call) => call.includes('after='))).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps walking when the searched post is deeper in the window', async () => {
|
||||
calls.length = 0
|
||||
const result = await loadInstagramFeed({
|
||||
...args,
|
||||
maxResults: 500,
|
||||
shouldStopAt: (post) => post.id === 'post3',
|
||||
fetchImpl: async (input) => {
|
||||
calls.push(input)
|
||||
if (input.includes('/media')) {
|
||||
if (input.includes('after=cursor-1')) {
|
||||
return fakeResponse({
|
||||
data: [MEDIA_ITEM('post3')],
|
||||
paging: { cursors: { after: 'cursor-2' } },
|
||||
})
|
||||
}
|
||||
return fakeResponse({
|
||||
data: [MEDIA_ITEM('post1'), MEDIA_ITEM('post2')],
|
||||
paging: { cursors: { after: 'cursor-1' } },
|
||||
})
|
||||
}
|
||||
return fakeResponse({ username: 'depjorgesolla' })
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.posts.map((post) => post.id)).toEqual(['post1', 'post2', 'post3'])
|
||||
const mediaCalls = calls.filter((call) => call.includes('/media'))
|
||||
expect(mediaCalls).toHaveLength(2)
|
||||
expect(calls.some((call) => call.includes('after=cursor-2'))).toBe(false)
|
||||
})
|
||||
|
||||
it('refreshes once and refetches the whole window when a later page fails', async () => {
|
||||
calls.length = 0
|
||||
let mediaCalls = 0
|
||||
|
|
|
|||
Loading…
Reference in a new issue