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 deltas
Cada conexão segue o mesmo ciclo de vida em ambos os transportes:
connected— confirmação com seu tier, filtros e o descritorresume(abaixo).- Snapshot inicial — o estado atual completo para seus canais e filtros inscritos (SSE: chunks
snapshot; WS:opportunities_snapshot+ mensagensinitialpor livro). snapshot:complete— a linha de base está pronta; deltas ao vivo se seguem.- 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):
| Canal | Eventos delta | Indexar o estado local por | Sinal de remoção |
|---|---|---|---|
odds | odds:update, odds:locked | id em cada linha — estável para a tupla (evento, sportsbook, mercado, seleção); line se move sob o mesmo id | odds:removed carrega ids para deletar |
Oportunidades (ev, arbitrage, middles, low_hold) | *:detected | id em cada oportunidade | *:expired carrega o array expired de IDs |
gamestate (WS) | gamestate:update (linhas modificadas) | event_id | gamestate: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 delta | eventos encerrados param de aparecer |
closing_line (WS) | closing_line:captured | feed somente-append — nada para mesclar ou remover | — |
2. O resume é de melhor esforço — diga isso claramente
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
connectede pelosnapshot:completeque 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 resume
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"]
}| Campo | Significado |
|---|---|
durable | Hoje 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. |
enabled | Se este transporte tenta replay atualmente. Quando false, cada reconexão recebe um snapshot completo (com fallback_reason: "disabled" se você enviou um cursor). |
scope | process_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_channels | Quais canais a janela de replay cobre neste transporte (abaixo). |
A cobertura difere por transporte:
| Transporte | resumable_channels | Cursor |
|---|---|---|
| WebSocket | odds, 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. |
| SSE | Apenas odds | Header 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 tratar
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:complete | O que aconteceu | O que seu cliente faz |
|---|---|---|
mode: "full_resync" (+ fallback_reason) | O servidor não conseguiu retomar — um snapshot completo foi enviado em vez disso | Descarte todo o estado local anterior. O snapshot que você acabou de receber é a nova linha de base autoritativa. |
mode: "resume" com gap_detected: true | O 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_reason | Por quê |
|---|---|
seq_too_old | Seu cursor caiu fora da janela de replay (desconectado por muito tempo, ou o alto volume encurtou a janela) |
process_restarted | O stream foi re-baseado por manutenção do servidor — cursores anteriores são estruturalmente não retomáveis |
foreign_seq | Seu cursor foi gerado por uma instância de servidor diferente da que você se reconectou |
filter_changed | Você se reconectou com filtros diferentes — reproduzir o escopo antigo serviria dados com filtros errados, então o servidor re-estabelece a base |
channel_unsupported | Este canal não está em resumable_channels para este transporte (permanente — ex. SSE gamestate) |
gap_too_large | A lacuna é tecnicamente reproduzível, mas tão grande que um snapshot fresco é mais barato e converge mais rápido |
disabled | O resume está atualmente desativado para este transporte (resume.enabled: false) |
parse_error | O 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 lento
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:
- Buffering. Cada conexão tem um buffer de envio limitado que absorve rajadas normais.
- 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_requiredantes da próxima mensagem de dados, para que você saiba que seu estado está agora incompleto: recarregue via REST/oddsou reconecte. A fase de snapshot inicial nunca é saltada — apenas os deltas ao vivo. - 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ícito
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
4001com 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 streaming
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
errorcom códigotier_restrictedouinvalid_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 cliente
- Aplicar o snapshot inicial, depois mesclar deltas pela chave estável para cada canal (§1); deletar em
odds:removed/*:expired. - Aguardar
snapshot:completeantes de tratar o estado como completo. - Persistir seu cursor (
Last-Event-ID/ últimoglobal_seq) e apresentá-lo ao reconectar. - Ao reconectar, ramificar conforme o resultado:
full_resync→ descartar estado;resume+gap_detected→ recarregar via REST/odds;resumelimpo → continuar (§4). - Tratar
resync_required→ recarregar ou reconectar (§5). - Reconectar com backoff + jitter em qualquer fechamento exceto
displaced/4001 displaced(§6). - Em
4003/ SSEerror(tier_restricted,invalid_api_key) → reconectar para reautorizar (§7). - Observar heartbeats: nenhum por 60s, ou
global_seqplano durante jogos ao vivo → reconectar (§5).
Implementações de referência
SSE — reconexão com Last-Event-ID (navegador)
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_seq
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ém
- Visão Geral do Streaming — protocolos, canais, quick start
- Referência da API SSE — payloads de eventos e parâmetros
- Referência da API WebSocket — esquemas de mensagens e códigos de fechamento
- Uma Conexão, Vários Tópicos — padrões de filtros que fazem uma única conexão ser suficiente