Skip to Content
StreamingReliability Contract

Streaming Reliability Contract

O que o stream garante, o que ele deliberadamente não garante, e exatamente o que seu cliente deve implementar para consumi-lo corretamente. Esta página é o contrato; as referências de SSE e WebSocket documentam os formatos de mensagens individuais.

O princípio de design: frescor antes de completude. Odds são perecíveis — um delta entregue tarde é pior do que um snapshot fresco, porque um preço desatualizado parece idêntico a um ao vivo. Cada política abaixo (retomada limitada, salto de mensagens sob contrapressão, desconexão de clientes que não conseguem acompanhar) decorre desse princípio. Construa seu cliente para tratar desconexão → reconexão → re-baseline como operação normal, não como caminho de erro.

1. Snapshot, depois deltasPermalink for this section

Cada conexão segue o mesmo ciclo de vida em ambos os transportes:

  1. connected — confirmação com seu tier, filtros e o descritor resume (abaixo).
  2. Snapshot inicial — o estado atual completo para seus canais e filtros inscritos (SSE: chunks snapshot; WS: opportunities_snapshot + mensagens initial por livro).
  3. snapshot:complete — a linha de base está pronta; deltas ao vivo se seguem.
  4. Deltas — atualizações incrementais que você mescla no estado local.

Os deltas são indexados por um identificador estável por canal. Mescle por essa chave — nunca por uma composta que você mesmo construa (consulte o aviso sobre id):

CanalEventos deltaIndexar o estado local porSinal de remoção
oddsodds:update, odds:lockedid em cada linha — estável para a tupla (evento, sportsbook, mercado, seleção); line se move sob o mesmo idodds:removed carrega ids para deletar
Oportunidades (ev, arbitrage, middles, low_hold)*:detectedid em cada oportunidade*:expired carrega o array expired de IDs
gamestate (WS)gamestate:update (linhas modificadas)event_idgamestate:removed carrega IDs de eventos
gamestate (SSE)gamestate:update (re-emissão completa do slate)substituir o slate inteiro a cada atualização — não é um deltaeventos encerrados param de aparecer
closing_line (WS)closing_line:capturedfeed somente-append — nada para mesclar ou remover

2. O resume é de melhor esforço — diga isso claramentePermalink for this section

Reconectar com um cursor (Last-Event-ID no SSE, from_seq no WS) permite ao servidor tentar reproduzir o que você perdeu em vez de reenviar um snapshot completo. Isso funciona bem para quedas breves. A janela é por transporte: aproximadamente os últimos 5 minutos no WebSocket, aproximadamente os últimos 2 minutos no SSE. Ambas também são limitadas pela contagem de entradas (WebSocket adicionalmente por bytes), portanto o volume sustentado as encurta ainda mais.

Não é um log durável sem lacunas:

  • A janela de replay vive na memória da instância do servidor que atendeu sua conexão. Uma reconexão que cai em uma instância diferente, ou que cruza um deploy ou reinício do servidor, não pode retomar e re-estabelece a base com um snapshot completo.
  • O fallback é explícito, nunca silencioso. Você sempre saberá o resultado pelo ack connected e pelo snapshot:complete que se segue — o servidor nunca finge que uma reconexão com perda foi sem lacunas.

Projete para o snapshot completo como caminho de recuperação rotineiro e trate uma retomada bem-sucedida como uma otimização. Se seu caso de uso não puder tolerar o re-baselining, reconcilie contra REST /api/v1/odds após qualquer reconexão.

3. O descritor resumePermalink for this section

Cada ack connected — SSE e WS, conexão nova ou reconexão — carrega um objeto resume autodescritivo para que seu cliente possa descobrir o contrato com antecedência:

"resume": { "durable": false, "enabled": true, "scope": "process_local", "resumable_channels": ["odds"] }
CampoSignificado
durableHoje false: o resume é uma otimização de melhor esforço, não um log durável. Se isso algum dia mudar para true, o resume passa a ser sem lacunas entre reconexões — até lá, implemente os caminhos de fallback abaixo.
enabledSe este transporte tenta replay atualmente. Quando false, cada reconexão recebe um snapshot completo (com fallback_reason: "disabled" se você enviou um cursor).
scopeprocess_local — a janela de replay está ligada à instância específica do servidor; reconexões que caem em outro lugar re-estabelecem a base.
resumable_channelsQuais canais a janela de replay cobre neste transporte (abaixo).

A cobertura difere por transporte:

Transporteresumable_channelsCursor
WebSocketodds, opportunities, gamestate, closing_line?from_seq= — o último global_seq que você viu (em formato de string; seguro para JS além de 253). Opcionalmente, retorne ?filter_hash= do seu ack connected para que o servidor possa verificar se seu escopo de filtro não mudou antes de reproduzir.
SSEApenas oddsHeader Last-Event-ID — enviado automaticamente por EventSource. Apenas eventos odds:update, odds:locked e odds:removed carregam linhas id:; trate o ID como um cursor opaco (retorne-o, nunca o analise). Reconexões em outros canais sempre voltam ao fallback (fallback_reason: "channel_unsupported").

O resume por WebSocket cobre estritamente mais canais — prefira WS para consumidores sensíveis à correção de oportunidades, gamestate ou dados de closing line.

4. Os dois resultados de recuperação que você DEVE tratarPermalink for this section

Uma reconexão com cursor se resolve em um de dois caminhos. O ack connected informa qual (resumed: true/false), e o snapshot:complete que se segue rotula o modo de recuperação:

Sinal snapshot:completeO que aconteceuO que seu cliente faz
mode: "full_resync" (+ fallback_reason)O servidor não conseguiu retomar — um snapshot completo foi enviado em vez dissoDescarte todo o estado local anterior. O snapshot que você acabou de receber é a nova linha de base autoritativa.
mode: "resume" com gap_detected: trueO replay começou, mas parte do intervalo perdido já havia sido despejado durante o replay (WS)Mantenha o estado local, mas recarregue os dados afetados via REST /odds — algumas atualizações da lacuna não foram reproduzidas.
mode: "resume" (sem gap_detected)O replay entregou tudo o que você perdeu (eventos replayed_count)Nada — você está contínuo. Deltas ao vivo se seguem.

Tratar apenas um caminho é o bug clássico de implementação: um cliente que só trata full_resync mantém silenciosamente linhas desatualizadas após um resume com lacuna; um cliente que só trata resume mistura um snapshot fresco em um mapa desatualizado. Trate os dois.

Em um fallback, connected carrega resumed: false mais um fallback_reason explicando o porquê (informativo — a ação de recuperação é a mesma em qualquer caso: aceitar o snapshot completo rotulado). Os valores incluem:

fallback_reasonPor quê
seq_too_oldSeu cursor caiu fora da janela de replay (desconectado por muito tempo, ou o alto volume encurtou a janela)
process_restartedO stream foi re-baseado por manutenção do servidor — cursores anteriores são estruturalmente não retomáveis
foreign_seqSeu cursor foi gerado por uma instância de servidor diferente da que você se reconectou
filter_changedVocê se reconectou com filtros diferentes — reproduzir o escopo antigo serviria dados com filtros errados, então o servidor re-estabelece a base
channel_unsupportedEste canal não está em resumable_channels para este transporte (permanente — ex. SSE gamestate)
gap_too_largeA lacuna é tecnicamente reproduzível, mas tão grande que um snapshot fresco é mais barato e converge mais rápido
disabledO resume está atualmente desativado para este transporte (resume.enabled: false)
parse_errorO cursor estava malformado

Trate isso como um conjunto aberto — novos valores podem aparecer; valores desconhecidos significam o mesmo (um snapshot completo se segue).

5. Política de consumidor lentoPermalink for this section

O servidor nunca deixa um cliente lento segurar o feed — nem servir preços desatualizados. A entrega degrada em três estágios explícitos:

  1. Buffering. Cada conexão tem um buffer de envio limitado que absorve rajadas normais.
  2. Salto. Sob contrapressão sustentada, mensagens delta ao vivo são saltadas em vez de enfileiradas até ficarem desatualizadas. Quando a pressão cede, o servidor envia uma mensagem de controle resync_required antes da próxima mensagem de dados, para que você saiba que seu estado está agora incompleto: recarregue via REST /odds ou reconecte. A fase de snapshot inicial nunca é saltada — apenas os deltas ao vivo.
  3. Desconexão. Um cliente que permanece muito lento é desconectado — fechamento WS 1008 (motivo "sustained backpressure — reconnect" ou "sustained skip rate — reconnect"), teardown do stream SSE. Reconecte e tome um snapshot fresco; entregar a você um backlog de odds desatualizadas seria pior.
{ "type": "resync_required", "reason": "backpressure", "message": "Deltas were dropped due to slow consumption. Request /api/v1/odds for a full snapshot or reconnect." }

Se você chega ao estágio 2 ou 3 regularmente: restrinja sua inscrição (canais, filtros sport, league, sportsbook, market), mova o parsing de JSON para fora do loop de recebimento, ou consuma de uma posição de rede mais rápida.

Detectando um stream parado. Heartbeats chegam a cada 30 segundos em ambos os transportes e carregam o seq / global_seq atual. Heartbeats fluindo enquanto global_seq fica plano durante jogos ao vivo significa que sua inscrição está congelada — reconecte proativamente. Heartbeats SSE carregam adicionalmente book_updated_ms (relógios de última emissão por livro) para que você possa detectar um único livro ficando quieto enquanto outros fazem streaming. Nenhum heartbeat por mais de 60 segundos significa que a conexão está morta — reconecte.

Etiqueta de reconexão. Use backoff exponencial com jitter (ex. 1s → 2s → 4s … limitado a 30s), reinicie em uma connected bem-sucedida. EventSource tenta novamente automaticamente (o servidor sugere retry: 3000); clientes WebSocket implementam seu próprio loop.

6. Uma conexão por chave — o deslocamento é explícitoPermalink for this section

Cada chave de API mantém um slot de stream por padrão, compartilhado entre SSE e WS, com semântica newer-wins: uma segunda conexão desloca a primeira, e a nova conexão sempre tem sucesso. A conexão deslocada é notificada explicitamente:

  • WS: código de fechamento 4001 com motivo "displaced by newer session".
  • SSE: um evento final displaced (code: "too_many_streams", reconnect: false), depois o stream fecha.

Não reconecte automaticamente após um deslocamento — o slot está sendo mantido pela sua sessão mais nova, e reconectar a deslocaria diretamente de volta (uma tempestade de reconexão autoinfligida). Uma conexão bem filtrada cobre efetivamente tudo — consulte Uma Conexão, Vários Tópicos. Frotas que genuinamente precisam de streams paralelos solicitam um limite maior por chave (hello@sharpapi.io) ou usam uma chave por processo.

7. As permissões são verificadas novamente enquanto você faz streamingPermalink for this section

A autorização não é apenas no momento da conexão: o servidor verifica periodicamente as permissões de cada conexão de streaming, e imediatamente após uma mudança de inscrição. Se sua chave perder acesso durante o stream (downgrade, chave revogada, remoção de add-on), o stream fecha explicitamente em vez de continuar com o grant desatualizado:

  • WS: código de fechamento 4003 (o motivo indica o que mudou, ex. tier ou acesso a streaming).
  • SSE: um evento error com código tier_restricted ou invalid_api_key, depois o stream fecha.

Reconecte para obter suas permissões atuais. Upgrades funcionam da mesma forma — uma conexão mantém seu grant do momento da conexão, então reconecte após atualizar para obter o novo acesso.

Lista de verificação do clientePermalink for this section

  • Aplicar o snapshot inicial, depois mesclar deltas pela chave estável para cada canal (§1); deletar em odds:removed / *:expired.
  • Aguardar snapshot:complete antes de tratar o estado como completo.
  • Persistir seu cursor (Last-Event-ID / último global_seq) e apresentá-lo ao reconectar.
  • Ao reconectar, ramificar conforme o resultado: full_resync → descartar estado; resume + gap_detected → recarregar via REST /odds; resume limpo → continuar (§4).
  • Tratar resync_required → recarregar ou reconectar (§5).
  • Reconectar com backoff + jitter em qualquer fechamento exceto displaced / 4001 displaced (§6).
  • Em 4003 / SSE error (tier_restricted, invalid_api_key) → reconectar para reautorizar (§7).
  • Observar heartbeats: nenhum por 60s, ou global_seq plano durante jogos ao vivo → reconectar (§5).

Implementações de referênciaPermalink for this section

SSE — reconexão com Last-Event-ID (navegador)Permalink for this section

EventSource reenvia Last-Event-ID automaticamente; seu trabalho é apenas ramificar conforme o resultado de recuperação:

const oddsMap = new Map(); const es = new EventSource( 'https://api.sharpapi.io/api/v1/stream?channel=odds&league=nba&api_key=YOUR_KEY' ); es.addEventListener('connected', (e) => { const ack = JSON.parse(e.data); // ack.resume describes the contract: { durable, enabled, scope, resumable_channels } if (ack.resumed === false) { // Resume fell back — a full snapshot follows. Discard stale state NOW, // before the snapshot chunks arrive. Do NOT clear when ack.resumed is // true: no snapshot follows a successful resume, only replayed deltas. oddsMap.clear(); console.log('re-baselining:', ack.fallback_reason); } }); es.addEventListener('snapshot', (e) => { for (const odd of JSON.parse(e.data).odds) oddsMap.set(odd.id, odd); }); es.addEventListener('snapshot:complete', (e) => { const { mode } = JSON.parse(e.data); // mode "full_resync": the snapshot above is the new baseline. // mode "resume": replayed deltas already merged — state is continuous. // No mode key: fresh connect. Either way, state is complete from here. console.log('ready', mode ?? 'fresh'); }); es.addEventListener('odds:update', (e) => { for (const delta of JSON.parse(e.data).odds) { const row = oddsMap.get(delta.id); if (row) Object.assign(row, delta); } }); es.addEventListener('odds:removed', (e) => { for (const id of JSON.parse(e.data).ids) oddsMap.delete(id); }); es.addEventListener('resync_required', () => { // Deltas were skipped while we were slow — state is incomplete. es.close(); // simplest recovery: reconnect for a fresh snapshot reconnectWithBackoff(); }); es.addEventListener('displaced', () => { es.close(); // newer session on this key took the slot — // do NOT reconnect automatically (§6) });

WebSocket — retomada com from_seqPermalink for this section

let lastGlobalSeq = null; let delay = 1000; function connect() { const params = new URLSearchParams({ api_key: 'YOUR_KEY', channels: 'odds,ev', }); if (lastGlobalSeq) params.set('from_seq', lastGlobalSeq); // resume attempt const ws = new WebSocket(`wss://ws.sharpapi.io?${params}`); ws.onmessage = (event) => { const msg = JSON.parse(event.data); if (msg.global_seq) lastGlobalSeq = msg.global_seq; // string — keep as-is switch (msg.type) { case 'connected': delay = 1000; // reset backoff if (msg.resumed === false) { oddsMap.clear(); // full snapshot follows — discard stale state console.log('resume fell back:', msg.fallback_reason); } break; case 'snapshot:complete': if (msg.mode === 'resume' && msg.gap_detected) { refetchFromRest(); // GET /api/v1/odds for your filter scope } break; case 'initial': for (const odd of msg.data) oddsMap.set(odd.id, odd); break; case 'odds:update': for (const odd of msg.data) oddsMap.set(odd.id, odd); break; case 'odds:removed': for (const id of msg.ids) oddsMap.delete(id); break; case 'resync_required': refetchFromRest(); // deltas were skipped — reconcile break; } }; ws.onclose = (event) => { if (event.code === 4001 && event.reason.includes('displaced')) { return; // newer session owns the slot — do not reconnect (§6) } // 4003 (entitlements changed), 1008 (too slow), 1012 (restart), network // drops: reconnect with backoff — from_seq makes it a resume attempt. setTimeout(connect, delay); delay = Math.min(delay * 2, 30000); }; } connect();

Veja tambémPermalink for this section

Last updated on