UNPKG

terriajs

Version:

Geospatial data visualization platform.

396 lines (360 loc) 13.7 kB
import i18next from "i18next"; import { action, runInAction } from "mobx"; import retry from "retry"; import formatError from "terriajs-cesium/Source/Core/formatError"; import Rectangle from "terriajs-cesium/Source/Core/Rectangle"; import Resource from "terriajs-cesium/Source/Core/Resource"; import TileProviderError from "terriajs-cesium/Source/Core/TileProviderError"; import ImageryProvider from "terriajs-cesium/Source/Scene/ImageryProvider"; import AbstractConstructor from "../Core/AbstractConstructor"; import TerriaError from "../Core/TerriaError"; import getUrlForImageryTile from "../Map/ImageryProvider/getUrlForImageryTile"; import { ProviderCoords } from "../Map/PickedFeatures/PickedFeatures"; import CompositeCatalogItem from "../Models/Catalog/CatalogItems/CompositeCatalogItem"; import CommonStrata from "../Models/Definition/CommonStrata"; import Model from "../Models/Definition/Model"; import CatalogMemberTraits from "../Traits/TraitsClasses/CatalogMemberTraits"; import ImageryProviderTraits from "../Traits/TraitsClasses/ImageryProviderTraits"; import MappableTraits from "../Traits/TraitsClasses/MappableTraits"; import DiscretelyTimeVaryingMixin from "./DiscretelyTimeVaryingMixin"; import MappableMixin from "./MappableMixin"; const LEAFLET_EMPTY_IMAGE_URL = "data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs="; type ModelType = Model< MappableTraits & ImageryProviderTraits & CatalogMemberTraits > & MappableMixin.Instance; /** * A mixin for handling tile errors in raster layers * */ function TileErrorHandlerMixin<T extends AbstractConstructor<ModelType>>( Base: T ) { abstract class TileErrorHandlerMixin extends Base { tileFailures = 0; private readonly tileRetriesByMap: Map<string, number> = new Map(); tileRetryOptions: retry.OperationOptions = { retries: 8, minTimeout: 200, randomize: true }; /** * A hook that may be implemented by catalog items for custom handling of tile errors. * * @param maybeError A tile request promise that that fails with the tile error * @param tile The tile to be fetched * @returns A modified promise * * The item can then decide to retry the tile request after adding custom parameters * like an authentication token. */ handleTileError?: ( request: Promise<void>, tile: ProviderCoords ) => Promise<void>; get hasTileErrorHandlerMixin() { return true; } /* * Handling tile errors is really complicated because: * * 1) things go wrong for a variety of weird reasons including server * misconfiguration, servers that are flakey but not totally broken, * etc. * * 2) we want to fail as gracefully as possible, and give flakey servers * every chance chance to shine * * 3) we don't generally have enough information the first time something fails. * * There are several mechanisms in play here: * - Cesium's Resource automatically keeps trying to load any resource * that fails until told to stop, but tells us each time. * - The "retry" library knows how to pace the retries, and when to actually stop. */ onTileLoadError(tileProviderError: TileProviderError): void { const operation = retry.operation(this.tileRetryOptions); // Cesium's TileProviderError has a native Promise now, but the code // below is adapted from when it had a when.js promise. That's why it's // a bit unusual looking. // // result.reject = stop trying to load this tile // result.resolve = retry loading this tile let result: { resolve: () => void; reject: (reason?: any) => void }; const promise = new Promise<void>((resolve, reject) => { result = { resolve, reject }; }); const imageryProvider = tileProviderError.provider as ImageryProvider; const tile = { x: tileProviderError.x, y: tileProviderError.y, level: tileProviderError.level }; // We're only concerned about failures for tiles that actually overlap this item's extent. if ( isTileOutsideExtent( tile, runInAction(() => this.cesiumRectangle), imageryProvider ) ) { tileProviderError.retry = false; return; } /** Helper methods **/ // Give up loading this (definitively, unexpectedly bad) tile and // possibly give up on this layer entirely. const failTile = action((e: Error) => { this.tileFailures += 1; const opts = this.tileErrorHandlingOptions; const thresholdBeforeDisablingItem = opts.thresholdBeforeDisablingItem === undefined ? 5 : opts.thresholdBeforeDisablingItem; if (this.tileFailures > thresholdBeforeDisablingItem && this.show) { if (isThisItemABaseMap()) { this.terria.raiseErrorToUser( new TerriaError({ sender: this, title: i18next.t( "models.imageryLayer.accessingBaseMapErrorTitle" ), message: i18next.t( "models.imageryLayer.accessingBaseMapErrorMessage", { name: this.name } ) + "<pre>" + formatError(e) + "</pre>" }) ); } else { this.terria.raiseErrorToUser( new TerriaError({ sender: this, title: i18next.t( "models.imageryLayer.accessingCatalogItemErrorTitle" ), message: i18next.t( "models.imageryLayer.accessingCatalogItemErrorMessage", { name: this.name } ) + "<pre>" + formatError(e) + "</pre>" }) ); } this.setTrait(CommonStrata.user, "show", false); } operation.stop(); result.reject(); }); const tellMapToSilentlyGiveUp = () => { operation.stop(); result.reject(); }; const retryWithBackoff = (e: Error) => { if (!operation.retry(e)) { failTile(e); } }; const tellMapToRetry = () => { operation.stop(); result.resolve(); }; const getTileKey = (tile: ProviderCoords) => { const time = DiscretelyTimeVaryingMixin.isMixedInto(this) ? this.currentTime : ""; const key = `L${tile.level}X${tile.x}Y${tile.y}${time}`; return key; }; const isThisItemABaseMap = () => { const baseMap = this.terria.mainViewer.baseMap; return this === (baseMap as any) ? true : baseMap instanceof CompositeCatalogItem ? baseMap.memberModels.includes(this) : false; }; /** End helper methods **/ // By setting retry to a promise, we tell cesium/leaflet to // reload the tile if the promise resolves successfully // https://github.com/TerriaJS/cesium/blob/terriajs/Source/Core/TileProviderError.js#L161 tileProviderError.retry = promise; if (tileProviderError.timesRetried === 0) { // There was an intervening success, so restart our count of the tile failures. this.tileFailures = 0; this.tileRetriesByMap.clear(); } operation.attempt( action(async (attemptNumber) => { if (this.show === false) { // If the layer is no longer shown, ignore errors and don't retry. tellMapToSilentlyGiveUp(); return; } const { ignoreUnknownTileErrors, treat403AsError, treat404AsError } = this.tileErrorHandlingOptions; // Browsers don't tell us much about a failed image load, so we do an // XHR to get more error information if needed. const maybeXhr = attemptNumber === 1 ? Promise.reject(tileProviderError.error) : fetchTileImage(tile, imageryProvider); if (this.handleTileError && maybeXhr) { // Give the catalog item a chance to handle this error. this.handleTileError(maybeXhr as any, tile); } try { await maybeXhr; const key = getTileKey(tile); const retriesByMap = this.tileRetriesByMap .set(key, (this.tileRetriesByMap.get(key) || 0) + 1) .get(key) || 0; if (retriesByMap > 5) { // Be careful: it's conceivable that a request here will always // succeed while a request made by the map will always fail, // e.g. as a result of different request headers. We must not get // stuck repeating the request forever in that scenario. Instead, // we should give up after a few attempts. failTile({ name: i18next.t("models.imageryLayer.tileErrorTitle"), message: i18next.t("models.imageryLayer.tileErrorMessage", { url: getUrlForImageryTile( imageryProvider, tileProviderError.x, tileProviderError.y, tileProviderError.level ) }) }); } else { // Either: // - the XHR request for more information surprisingly worked // this time, let's hope the good luck continues when Cesium/Leaflet retries. // - the ImageryCatalogItem looked at the error and said we should try again. tellMapToRetry(); } } catch (error: any) { // This attempt failed. We'll either retry (for 500s) or give up // depending on the status code. const e: Error & { statusCode?: number; target?: HTMLImageElement; } = error || {}; if (e.statusCode === undefined) { if (runInAction(() => ignoreUnknownTileErrors)) { tellMapToSilentlyGiveUp(); } else if (e.target !== undefined) { // This is a failed image element, which means we got a 200 response but // could not load it as an image. // If image element is Leaflet's empty pixel ignore this error (See: https://github.com/Leaflet/Leaflet/issues/9311) if (e.target.src === LEAFLET_EMPTY_IMAGE_URL) { tellMapToSilentlyGiveUp(); return; } failTile({ name: i18next.t("models.imageryLayer.tileErrorTitle"), message: i18next.t("models.imageryLayer.tileErrorMessageII", { url: getUrlForImageryTile( imageryProvider, tile.x, tile.y, tile.level ) }) }); } else { // Unknown error failTile({ name: i18next.t("models.imageryLayer.unknownTileErrorTitle"), message: i18next.t( "models.imageryLayer.unknownTileErrorMessage", { url: getUrlForImageryTile( imageryProvider, tile.x, tile.y, tile.level ) } ) }); } } else if (e.statusCode >= 400 && e.statusCode < 500) { if (e.statusCode === 403 && treat403AsError === false) { tellMapToSilentlyGiveUp(); } else if (e.statusCode === 404 && treat404AsError === false) { tellMapToSilentlyGiveUp(); } else { failTile(e); } } else if (e.statusCode >= 500 && e.statusCode < 600) { retryWithBackoff(e); } else { failTile(e); } } }) ); } } /** * Trying fetching image using an XHR request */ function fetchTileImage( tile: ProviderCoords, imageryProvider: ImageryProvider ) { const tileUrl = getUrlForImageryTile( imageryProvider, tile.x, tile.y, tile.level ); if (tileUrl) { return Resource.fetchImage({ url: tileUrl, preferBlob: true }); } return Promise.reject(); } function isTileOutsideExtent( tile: ProviderCoords, rectangle: Rectangle | undefined, imageryProvider: ImageryProvider ): boolean { if (rectangle === undefined) { // If the rectangle is not defined, assume the tile is inside. return false; } const tilingScheme = imageryProvider.tilingScheme; const tileExtent = tilingScheme.tileXYToRectangle( tile.x, tile.y, tile.level ); const intersection = Rectangle.intersection(tileExtent, rectangle); return intersection === undefined; } return TileErrorHandlerMixin; } namespace TileErrorHandlerMixin { export interface Instance extends InstanceType< ReturnType<typeof TileErrorHandlerMixin> > {} export function isMixedInto(model: any): model is Instance { return model?.hasTileErrorHandlerMixin; } } export default TileErrorHandlerMixin;