fix(street-level): load the coverage in view, and name a rejected token

Tilted views: the visible box came from screen rays against the bare
ellipsoid and the 9 tiles kept were the ones nearest its centre, so a view
over high ground (Denver, 1,600 m up) or near the horizon dropped the
streets in front of the camera. At street zooms the rays now meet an
ellipsoid raised to the ground under the camera, hits beyond a range tied
to camera height are ignored, tilted views also keep the ground around the
camera, and tiles are ranked along the line of sight (nadir to screen
centre). Simulated with real Cesium ray maths over Sacramento and Denver
from 2 m to 1,000 m: the nearest visible ground and the screen centre are
always loaded.

Rejected token: Mapillary's 401/403 reached the panel as a raw "Mapillary
tiles HTTP 403" while the pill said ON, and every pan asked Mapillary again.
The proxy now answers a refusal as {keyRejected: true} and holds it for that
token (5 min), and a 429 with its Retry-After (held until then). The panel
reads KEY REJECTED, names MAPILLARY_CLIENT_TOKEN and Provider Settings,
gates its controls like KEY REQUIRED, requests nothing more, and clears the
message when the layer goes off. A rate limit keeps the drawn tiles and
refreshes once the wait is over.

Found in the IC8 review of #768 (P1 tile selection, P2 rejected token).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Said TurkseverandClaude Opus 5.5 committed 2026-10-05 15:12:03 +01:00
1 parent 57c58a862a
commit 63280e22c0
19 files changed
+728 -43

No files matched your search

+10
View File
@@ -37,6 +37,16 @@ export const TILE_MAX_BYTES = 48 * 1024 * 1024;
/** In-memory tile cache budget (bytes) and upstream fetch timeout. */
export const TILE_MEMORY_BUDGET_BYTES = 96 * 1024 * 1024;
export const TILE_FETCH_TIMEOUT_MS = 60_000;
/**
* After Mapillary rejects the token (401/403), tile misses are answered from
* that verdict for this long instead of asking again on every camera move.
* A changed token is asked at once.
*/
export const TILE_KEY_REJECTED_HOLD_MS = 5 * 60 * 1000;
/** After a 429 without a usable Retry-After, hold tile misses this long. */
export const TILE_RATE_LIMIT_HOLD_MS = 60_000;
/** The longest Retry-After honoured, so a bad header cannot stall coverage. */
export const TILE_RATE_LIMIT_MAX_HOLD_MS = 10 * 60 * 1000;
/** The client token lives in the browser by design; the server adds it to tile URLs too. */
export function mapillaryToken() {
+16 -1
View File
@@ -46,10 +46,25 @@ async function handleTile(req, res) {
if (res.writableEnded || abandoned.signal.aborted) return;
if (error instanceof TileRequestError)
return sendJson(res, error.status, { error: error.message });
if (error instanceof TileUpstreamError)
if (error instanceof TileUpstreamError) {
// A rejected token is a key problem the panel can name, not a fault.
if (error.keyRejected)
return sendJson(res, 403, {
error: 'Mapillary rejected the access token',
keyRejected: true,
});
if (error.status === 429) {
const retryAfter = error.retryAfterSec || 60;
res.setHeader('Retry-After', String(retryAfter));
return sendJson(res, 429, {
error: 'Mapillary is rate-limiting tile requests',
retryAfter,
});
}
return sendJson(res, error.status >= 500 ? 502 : error.status, {
error: error.message,
});
}
sendJson(res, 502, { error: error?.message || 'Tile fetch failed' });
}
}
+58 -3
View File
@@ -9,6 +9,9 @@ import {
TILE_MAX_BYTES,
TILE_MEMORY_BUDGET_BYTES,
TILE_FETCH_TIMEOUT_MS,
TILE_KEY_REJECTED_HOLD_MS,
TILE_RATE_LIMIT_HOLD_MS,
TILE_RATE_LIMIT_MAX_HOLD_MS,
mapillaryToken,
} from './constants.js';
@@ -21,15 +24,52 @@ export class TileRequestError extends Error {
}
}
/** Thrown when Mapillary answered with an error status. */
/**
* Thrown when Mapillary answered with an error status. A 401/403 means the
* token was rejected (`keyRejected`); a 429 carries how long to wait.
*/
export class TileUpstreamError extends Error {
constructor(status, message) {
constructor(status, message, { retryAfterSec = null } = {}) {
super(message || `Mapillary tiles HTTP ${status}`);
this.name = 'TileUpstreamError';
this.status = status;
this.keyRejected = status === 401 || status === 403;
this.retryAfterSec = retryAfterSec;
}
}
/**
* The last refusal from Mapillary that every tile miss shares: a rejected
* token (until the token changes or the hold ends) or a rate limit (until its
* Retry-After). Cached tiles are still served meanwhile.
* @type {{status: number, until: number, token: string}|null}
*/
let _upstreamHold = null;
/** Milliseconds to wait from a Retry-After header (seconds or an HTTP date). */
function retryAfterMs(header, now = Date.now()) {
const text = String(header ?? '').trim();
let ms = NaN;
if (/^\d+$/.test(text)) ms = Number(text) * 1000;
else if (text) ms = Date.parse(text) - now;
if (!Number.isFinite(ms) || ms <= 0) return TILE_RATE_LIMIT_HOLD_MS;
return Math.min(ms, TILE_RATE_LIMIT_MAX_HOLD_MS);
}
/** The refusal to answer a miss with, while one holds for this token. */
function heldRefusal(token) {
const hold = _upstreamHold;
if (!hold) return null;
const left = hold.until - Date.now();
if (left <= 0 || hold.token !== token) {
_upstreamHold = null;
return null;
}
return new TileUpstreamError(hold.status, undefined, {
retryAfterSec: Math.ceil(left / 1000),
});
}
/**
* Validate and normalize a tile address. Layer names are the proxy's public
* ids (coverage/points/signs); zoom must be inside the layer's published range.
@@ -135,6 +175,8 @@ function writeDisk(address, bytes) {
async function fetchUpstream(address, signal) {
const token = mapillaryToken();
if (!token) throw new TileRequestError('Mapillary token not configured', 503);
const held = heldRefusal(token);
if (held) throw held;
const url = `${MAPILLARY_TILE_HOST}/${address.upstream}/2/${address.z}/${address.x}/${address.y}?access_token=${encodeURIComponent(token)}`;
const timeout = AbortSignal.timeout(TILE_FETCH_TIMEOUT_MS);
const response = await fetch(url, {
@@ -147,7 +189,19 @@ async function fetchUpstream(address, signal) {
}
if (!response.ok) {
await response.body?.cancel().catch(() => {});
throw new TileUpstreamError(response.status);
const error = new TileUpstreamError(response.status);
if (error.keyRejected)
_upstreamHold = {
status: response.status,
until: Date.now() + TILE_KEY_REJECTED_HOLD_MS,
token,
};
else if (response.status === 429) {
const wait = retryAfterMs(response.headers.get('retry-after'));
_upstreamHold = { status: 429, until: Date.now() + wait, token };
error.retryAfterSec = Math.ceil(wait / 1000);
}
throw error;
}
const declared = Number(response.headers.get('content-length'));
if (Number.isFinite(declared) && declared > TILE_MAX_BYTES) {
@@ -258,4 +312,5 @@ export function _resetTileMemoryForTest() {
_memory.clear();
_memoryBytes = 0;
_inFlight.clear();
_upstreamHold = null;
}