diff options
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/transport.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/transport.js | 87 |
1 files changed, 71 insertions, 16 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index f6adaac..79cd8e1 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -59,6 +59,57 @@ const UPLOAD_BUFFER_HIGH = 1024 * 1024; // that never answers costs a pause rather than the whole attempt. const ICE_GATHER_TIMEOUT_MS = 4000; +// Answers to an offer that are about the hub's load, never about the node: 429 +// is one of the hub's per-account ceilings (docs/MESHBAY_DESIGN.md §7.2), and +// 502/503 is the hub restarting behind its proxy. An offer refused for either is +// sent again, so it is not reported as a node that cannot be reached — which is +// what a single 429 used to become in Search, for a group whose node was +// answering every other offer in under a second. Anything else — 404 for a node +// that is not connected, 403, 504 for a node that did not answer — fails at once: +// retrying it would make a dead node cost time instead of costing nothing. +const OFFER_RETRY_STATUSES = new Set([429, 502, 503]); +// Worst case about twenty seconds of waiting, all of it on a hub that is +// answering. Search's own deadline treats each retry as progress and is still +// bounded by its ceiling. +const OFFER_RETRY_DELAYS_MS = [500, 1000, 2000, 4000, 8000]; +const OFFER_RETRY_AFTER_MAX_MS = 10000; + +/** + * POST an offer, sending it again while the hub refuses it for load. + * + * `Retry-After` is honoured when the hub gives one, and every delay is jittered: + * the refusals this exists for come in bursts — several groups dialled at once, + * or every connection of a phone reconnecting as it wakes — and retries that + * all land on the same millisecond would be refused together again. + * + * `isClosed` is asked after each wait, so a caller that gave up (Search's + * deadline, a page that unmounted) sends no further offer on its behalf. + */ +async function postOffer(call, url, init, { isClosed, onRetry } = {}) { + for (let attempt = 0; ; attempt += 1) { + const resp = await call(url, init); + if (resp.ok) return resp; + if (!OFFER_RETRY_STATUSES.has(resp.status) || attempt >= OFFER_RETRY_DELAYS_MS.length) { + const detail = await resp.json().catch(() => ({})); + const err = new Error(`Signaling failed: ${resp.status} ${detail.detail || ''}`); + err.status = resp.status; + throw err; + } + const after = Number(resp.headers && resp.headers.get && resp.headers.get('Retry-After')); + const base = after > 0 + ? Math.min(after * 1000, OFFER_RETRY_AFTER_MAX_MS) + : OFFER_RETRY_DELAYS_MS[attempt]; + const delay = Math.round(base * (0.75 + Math.random() * 0.5)); + if (onRetry) onRetry(resp.status, delay, attempt + 1); + await new Promise((resolve) => setTimeout(resolve, delay)); + if (isClosed && isClosed()) { + const err = new Error('Transport closed while the hub was busy'); + err.status = resp.status; + throw err; + } + } +} + const STREAM_CREDITS = 24; function _aborted() { @@ -823,23 +874,27 @@ class MeshBayTransport { // app:// origin is refused by CORS. const call = (window.MeshBayPlatform && window.MeshBayPlatform.apiFetch) || fetch; - const resp = await call( + const resp = await postOffer(call, `${this._hubUrl}/v1/nodes/${nodeId}/webrtc/offer`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${this._accessToken}`, - }, - body: JSON.stringify({ - sdp: this._pc.localDescription.sdp, - ice_candidates: [], - }), - }); - - if (!resp.ok) { - const detail = await resp.json().catch(() => ({})); - throw new Error(`Signaling failed: ${resp.status} ${detail.detail || ''}`); - } + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this._accessToken}`, + }, + body: JSON.stringify({ + sdp: this._pc.localDescription.sdp, + ice_candidates: [], + }), + }, { + isClosed: () => this._closed, + onRetry: (status, delayMs, attempt) => { + console.warn('[MeshBay] Hub refused the offer with', status, + '— sending it again in', delayMs, 'ms (attempt', attempt, ')'); + trace('offer_retry', { status, delay_ms: delayMs, attempt }); + // The hub answered: a busy hub, not a silent node. + this._noteConnectProgress('offer_retry'); + }, + }); const answer = await resp.json(); this._rawAnswerSdp = answer.sdp; |