Streaming Reliability Contract
Was der Stream garantiert, was er bewusst nicht garantiert, und was genau Ihr Client implementieren muss, um ihn korrekt zu konsumieren. Diese Seite ist der Vertrag; die SSE- und WebSocket-Referenzen dokumentieren die einzelnen Nachrichtenformate.
Das Designprinzip: Aktualität vor Vollständigkeit. Quoten sind vergänglich — ein zu spät zugestelltes Delta ist schlimmer als ein frischer Snapshot, da ein veralteter Preis identisch zu einem aktuellen aussieht. Jede nachstehende Policy (begrenztes Resume, Überspringen von Nachrichten bei Gegendruck, Trennen von Clients, die nicht mithalten können) folgt aus diesem Prinzip. Bauen Sie Ihren Client so, dass er Trennung → Wiederverbindung → Neubaseline als normalen Betrieb behandelt und nicht als Fehlerpfad.
1. Snapshot, dann Deltas
Jede Verbindung folgt auf beiden Transportwegen demselben Lebenszyklus:
connected— Bestätigung mit Ihrem Tier, Filtern und demresume-Deskriptor (unten).- Initialer Snapshot — der vollständige aktuelle Zustand für Ihre abonnierten Kanäle und Filter (SSE:
snapshot-Chunks; WS:opportunities_snapshot+ pro Buchinitial-Nachrichten). snapshot:complete— die Baseline ist abgeschlossen; es folgen Live-Deltas.- Deltas — inkrementelle Updates, die Sie in den lokalen Zustand einpflegen.
Deltas sind durch einen stabilen Bezeichner pro Kanal indexiert. Pflegen Sie sie anhand dieses Schlüssels ein — niemals anhand eines selbst zusammengesetzten Schlüssels (siehe den id-Hinweis):
| Kanal | Delta-Ereignisse | Lokalen Zustand indexieren nach | Löschsignal |
|---|---|---|---|
odds | odds:update, odds:locked | id an jeder Zeile — stabil für das Tupel (Event, Sportsbook, Market, Selection); line bewegt sich unter derselben id | odds:removed enthält ids zum Löschen |
Opportunitäten (ev, arbitrage, middles, low_hold) | *:detected | id an jeder Opportunität | *:expired enthält expired-ID-Array |
gamestate (WS) | gamestate:update (geänderte Zeilen) | event_id | gamestate:removed enthält Event-IDs |
gamestate (SSE) | gamestate:update (vollständige Slate-Neuemission) | gesamten Slate bei jedem Update ersetzen — kein Delta | beendete Events erscheinen nicht mehr |
closing_line (WS) | closing_line:captured | Nur-Append-Feed — nichts einzupflegen oder zu entfernen | — |
2. Resume ist Best-Effort — klipp und klar
Beim Wiederverbinden mit einem Cursor (Last-Event-ID bei SSE, from_seq bei WS) kann der Server versuchen, verpasste Ereignisse erneut abzuspielen, statt einen vollständigen Snapshot neu zu senden. Dies funktioniert gut bei kurzen Ausfällen. Das Fenster ist je Transport unterschiedlich: ca. die letzten 5 Minuten bei WebSocket, ca. die letzten 2 Minuten bei SSE. Beide sind zudem durch die Anzahl der Einträge begrenzt (WebSocket zusätzlich durch Bytes), sodass hohes Volumen das Fenster weiter verkürzt.
Es ist kein dauerhaftes, lückenloses Protokoll:
- Das Replay-Fenster lebt im Speicher der Serverinstanz, die Ihre Verbindung bedient hat. Ein Wiederverbinden, das auf einer anderen Instanz landet, oder das eine Server-Deployments oder einen Neustart überschreitet, kann nicht fortgesetzt werden und erstellt eine neue Baseline mit einem vollständigen Snapshot.
- Der Fallback ist explizit, niemals still. Das Ergebnis wird immer im
connected-Ack und dem folgendensnapshot:completemitgeteilt — der Server gibt niemals vor, dass eine verlustbehaftete Wiederverbindung lückenlos war.
Entwerfen Sie den vollständigen Snapshot als routinemäßigen Recovery-Pfad und behandeln Sie ein erfolgreiches Resume als Optimierung. Wenn Ihr Anwendungsfall kein Neu-Baselining tolerieren kann, gleichen Sie nach jeder Wiederverbindung gegen REST /api/v1/odds ab.
3. Der resume-Deskriptor
Jedes connected-Ack — SSE und WS, frische Verbindung oder Wiederverbindung — enthält ein selbstbeschreibendes resume-Objekt, damit Ihr Client den Vertrag vorab erkennen kann:
"resume": {
"durable": false,
"enabled": true,
"scope": "process_local",
"resumable_channels": ["odds"]
}| Feld | Bedeutung |
|---|---|
durable | Heute false: Resume ist eine Best-Effort-Optimierung, kein dauerhaftes Protokoll. Sollte dies jemals zu true wechseln, ist Resume lückenlos über Wiederverbindungen hinweg — bis dahin implementieren Sie die unten stehenden Fallback-Pfade. |
enabled | Ob dieser Transport derzeit überhaupt einen Replay versucht. Wenn false, erhält jede Wiederverbindung einen vollständigen Snapshot (mit fallback_reason: "disabled", wenn Sie einen Cursor gesendet haben). |
scope | process_local — das Replay-Fenster ist an die jeweilige Serverinstanz gebunden; Wiederverbindungen, die anderswo landen, erhalten eine neue Baseline. |
resumable_channels | Welche Kanäle das Replay-Fenster auf diesem Transport abdeckt (unten). |
Die Abdeckung ist je Transport unterschiedlich:
| Transport | resumable_channels | Cursor |
|---|---|---|
| WebSocket | odds, opportunities, gamestate, closing_line | ?from_seq= — das letzte global_seq, das Sie gesehen haben (als String; JS-sicher über 253). Optional ?filter_hash= aus Ihrem connected-Ack zurücksenden, damit der Server prüfen kann, ob Ihr Filterbereich unverändert ist, bevor er einen Replay ausführt. |
| SSE | Nur odds | Last-Event-ID-Header — wird von EventSource automatisch gesendet. Nur odds:update-, odds:locked- und odds:removed-Ereignisse tragen id:-Zeilen; behandeln Sie die ID als opaken Cursor (zurückgeben, niemals parsen). Wiederverbindungen auf anderen Kanälen fallen immer zurück (fallback_reason: "channel_unsupported"). |
WebSocket-Resume deckt strikt mehr Kanäle ab — bevorzugen Sie WS für korrektheitskritische Konsumenten von Opportunitäten, Gamestate oder Closing-Line-Daten.
4. Die zwei Recovery-Ergebnisse, die Sie behandeln MÜSSEN
Eine Wiederverbindung mit Cursor führt zu einem von zwei Pfaden. Das connected-Ack teilt Ihnen mit, welcher (resumed: true/false), und das folgende snapshot:complete kennzeichnet den Recovery-Modus:
snapshot:complete-Signal | Was passiert ist | Was Ihr Client tut |
|---|---|---|
mode: "full_resync" (+ fallback_reason) | Der Server konnte nicht fortfahren — stattdessen wurde ein vollständiger Snapshot gesendet | Verwerfen Sie den gesamten vorherigen lokalen Zustand. Der soeben empfangene Snapshot ist die neue autoritative Baseline. |
mode: "resume" mit gap_detected: true | Das Replay begann, aber ein Teil des verpassten Bereichs war bereits mitten im Replay bereinigt (WS) | Lokalen Zustand beibehalten, aber fehlende Daten über REST /odds nachladen — einige Updates in der Lücke wurden nicht wiedergegeben. |
mode: "resume" (kein gap_detected) | Das Replay hat alles Verpasste zugestellt (replayed_count Ereignisse) | Nichts — Sie sind kontinuierlich. Live-Deltas folgen. |
Nur einen Pfad zu behandeln ist der klassische Implementierungsfehler: Ein Client, der nur full_resync behandelt, behält nach einem lückenbehafteten Resume stillschweigend veraltete Zeilen; ein Client, der nur resume behandelt, mischt einen frischen Snapshot in eine veraltete Map. Behandeln Sie beide.
Bei einem Fallback enthält connected resumed: false sowie einen fallback_reason, der erklärt warum (informatorisch — die Recovery-Aktion ist in jedem Fall dieselbe: den gekennzeichneten vollständigen Snapshot akzeptieren). Mögliche Werte:
fallback_reason | Warum |
|---|---|
seq_too_old | Ihr Cursor lag außerhalb des Replay-Fensters (zu lange getrennt oder hohes Volumen hat das Fenster verkürzt) |
process_restarted | Der Stream wurde durch Serverwartung neu basiert — Cursor von davor sind strukturell nicht fortsetzbar |
foreign_seq | Ihr Cursor wurde von einer anderen Serverinstanz als der erzeugt, mit der Sie sich wiederverbunden haben |
filter_changed | Sie haben sich mit anderen Filtern wiederverbunden — das Abspielen des alten Geltungsbereichs würde falsch gefilterte Daten liefern, daher erstellt der Server eine neue Baseline |
channel_unsupported | Dieser Kanal ist für diesen Transport nicht in resumable_channels (dauerhaft — z. B. SSE gamestate) |
gap_too_large | Die Lücke ist technisch abspielbar, aber so groß, dass ein frischer Snapshot günstiger ist und schneller konvergiert |
disabled | Resume ist für diesen Transport derzeit deaktiviert (resume.enabled: false) |
parse_error | Der Cursor war fehlerhaft |
Behandeln Sie dies als offene Menge — neue Werte können erscheinen; unbekannte Werte bedeuten dasselbe (es folgt ein vollständiger Snapshot).
5. Slow-Consumer-Policy
Der Server lässt keinen langsamen Client den Feed aufhalten — oder veraltete Preise liefern. Die Lieferung verschlechtert sich in drei expliziten Stufen:
- Pufferung. Jede Verbindung hat einen begrenzten Sendepuffer, der normale Bursts abfedert.
- Überspringen. Bei anhaltendem Gegendruck werden Live-Delta-Nachrichten übersprungen anstatt in der Warteschlange auf Veraltung zu warten. Wenn der Druck nachlässt, sendet der Server eine
resync_required-Steuernachricht vor der nächsten Datennachricht, damit Sie wissen, dass Ihr Zustand nun unvollständig ist: Holen Sie die Daten über REST/oddsneu ab oder verbinden Sie sich erneut. Die initiale Snapshot-Phase wird niemals übersprungen — nur Live-Deltas. - Trennen. Ein Client, der zu langsam bleibt, wird getrennt — WS-Schließcode
1008(Grund"sustained backpressure — reconnect"oder"sustained skip rate — reconnect"), SSE-Stream-Teardown. Verbinden Sie sich erneut und nehmen Sie einen frischen Snapshot; Ihnen einen Rückstand veralteter Quoten zu liefern wäre schlimmer.
{
"type": "resync_required",
"reason": "backpressure",
"message": "Deltas were dropped due to slow consumption. Request /api/v1/odds for a full snapshot or reconnect."
}Wenn Sie regelmäßig Stufe 2 oder 3 erreichen: Schränken Sie Ihr Abonnement ein (Kanäle, sport-, league-, sportsbook-, market-Filter), verschieben Sie JSON-Parsing aus der Empfangsschleife oder konsumieren Sie von einer schnelleren Netzwerkposition.
Einen eingefrorenen Stream erkennen. Heartbeats kommen alle 30 Sekunden auf beiden Transporten an und enthalten das aktuelle seq / global_seq. Fließende Heartbeats, während global_seq bei Live-Spielen flach bleibt, bedeuten, dass Ihr Abonnement eingefroren ist — verbinden Sie sich proaktiv neu. SSE-Heartbeats enthalten zusätzlich book_updated_ms (pro-Buch letzte Emissionsuhren), sodass Sie erkennen können, wenn ein einzelnes Buch still wird, während andere streamen. Kein Heartbeat für mehr als 60 Sekunden bedeutet, dass die Verbindung tot ist — verbinden Sie sich neu.
Wiederverbindungs-Etikette. Verwenden Sie exponentiellen Backoff mit Jitter (z. B. 1s → 2s → 4s … bis 30s begrenzt), bei erfolgreicher connected-Verbindung zurücksetzen. EventSource versucht automatisch neu (der Server gibt retry: 3000 als Hinweis); WebSocket-Clients implementieren ihre eigene Schleife.
6. Eine Verbindung pro Key — Verdrängung ist explizit
Jeder API-Key hält einen Stream-Slot standardmäßig, gemeinsam genutzt über SSE und WS, mit Newer-Wins-Semantik: Eine zweite Verbindung verdrängt die erste, und die neue Verbindung ist immer erfolgreich. Die verdrängte Verbindung wird explizit benachrichtigt:
- WS: Schließcode
4001mit Grund"displaced by newer session". - SSE: Ein abschließendes
displaced-Ereignis (code: "too_many_streams",reconnect: false), dann schließt sich der Stream.
Verbinden Sie sich nach einer Verdrängung nicht automatisch neu — der Slot wird von Ihrer neueren Session gehalten, und eine Wiederverbindung würde diese sofort wieder verdrängen (ein selbstverschuldeter Reconnect-Sturm). Eine gut gefilterte Verbindung deckt praktisch alles ab — siehe Eine Verbindung, viele Themen. Flotten, die echte parallele Streams benötigen, fordern ein höheres per-Key-Limit an (hello@sharpapi.io) oder verwenden einen Key pro Prozess.
7. Berechtigungen werden während des Streams neu geprüft
Die Autorisierung gilt nicht nur zum Verbindungszeitpunkt: Der Server überprüft regelmäßig die Berechtigungen jeder Streaming-Verbindung und direkt nach einer Abonnement-Änderung. Wenn Ihr Key mid-stream den Zugriff verliert (Downgrade, widerrufener Key, Add-on-Entfernung), schließt sich der Stream explizit, anstatt auf dem veralteten Grant fortzufahren:
- WS: Schließcode
4003(Grund gibt an, was sich geändert hat, z. B. Tier oder Streaming-Zugang). - SSE: Ein
error-Ereignis mit Codetier_restrictedoderinvalid_api_key, dann schließt sich der Stream.
Verbinden Sie sich erneut, um Ihre aktuellen Berechtigungen zu erhalten. Upgrades funktionieren genauso — eine Verbindung behält ihren verbindungszeitlichen Grant; verbinden Sie sich nach einem Upgrade neu, um neuen Zugang zu erhalten.
Client-Checkliste
- Initialen Snapshot anwenden, dann Deltas nach dem stabilen Schlüssel für jeden Kanal einpflegen (§1); bei
odds:removed/*:expiredlöschen. - Auf
snapshot:completewarten, bevor der Zustand als vollständig gilt. - Cursor (
Last-Event-ID/ letztesglobal_seq) persistieren und bei Wiederverbindung vorlegen. - Bei Wiederverbindung nach dem Ergebnis verzweigen:
full_resync→ Zustand verwerfen;resume+gap_detected→ über REST/oddsnachladen; sauberesresume→ fortfahren (§4). -
resync_requiredbehandeln → nachladen oder neu verbinden (§5). - Bei allen Schließungen außer
displaced/4001 displacedmit Backoff + Jitter neu verbinden (§6). - Bei
4003/ SSEerror(tier_restricted,invalid_api_key) → neu verbinden zur Neuautorisierung (§7). - Heartbeats beobachten: 60s kein Heartbeat oder
global_seqbei Live-Spielen flach → neu verbinden (§5).
Referenzimplementierungen
SSE — Wiederverbindung mit Last-Event-ID (Browser)
EventSource sendet Last-Event-ID automatisch erneut; Ihre Aufgabe ist es nur, nach dem Recovery-Ergebnis zu verzweigen:
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 — Resume mit 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();Weitere Informationen
- Streaming-Übersicht — Protokolle, Kanäle, Quick Start
- SSE-API-Referenz — Ereignis-Payloads und Parameter
- WebSocket-API-Referenz — Nachrichtenschemata und Schließcodes
- Eine Verbindung, viele Themen — Filtermuster, mit denen ein Stream ausreicht