Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions docs/developer-guide/local-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,24 @@ This is the main structure:
"sig": "${sasToken}"
}
}],
// optional adaptive throttling for HTTP 429 Too Many Requests responses
"rateLimit": {
// enabled by default
"enabled": true,
// base exponential backoff delay in milliseconds when Retry-After is missing
"baseDelay": 1000,
// maximum exponential backoff delay in milliseconds
"maxDelay": 60000,
// null means retry until success, cancellation, or a non-429 error
"maxRetries": null,
// default bucketing strategy: "origin", "path" or "wmsLayer"
"defaultBucket": "origin",
// optional per-server bucketing rules
"bucketRules": [{
"urlPattern": ".*geoserver/wms.*",
"bucket": "wmsLayer"
}]
},
// flag for postponing mapstore 2 load time after theme
"loadAfterTheme": false,
// if defined, WMS layer styles localization will be added
Expand Down Expand Up @@ -211,6 +229,40 @@ For configuring plugins, see the [Configuring Plugins Section](plugins-documenta
!!! note "Backward Compatibility"
The old `useAuthenticationRules` and `authenticationRules` configuration still works and will be automatically converted to the new format. However, the new format is recommended for better flexibility and features like expiration support.

- `rateLimit`: configures adaptive request throttling for HTTP 429 responses. MapStore respects the `Retry-After` header when present, including both seconds and HTTP-date formats, and uses exponential backoff when the header is missing.

**Configuration options:**
- `enabled` - Boolean to enable or disable adaptive throttling. Default is `true`.
- `baseDelay` - First exponential backoff delay in milliseconds when `Retry-After` is missing. Default is `1000`.
- `maxDelay` - Maximum exponential backoff delay in milliseconds. Default is `60000`.
- `maxRetries` - Maximum number of consecutive retries per bucket. `null` retries until success, cancellation, or a non-429 error. Default is `null`.
- `defaultBucket` - Default throttling scope. Supported values are `origin`, `path`, and `wmsLayer`. Default is `origin`.
- `bucketRules` - Array of `{ "urlPattern": "...", "bucket": "..." }` rules used to override the default bucket for matching URLs. `wmsLayer` ignores tile-specific parameters such as `BBOX`, `WIDTH`, `HEIGHT`, `SRS`, and `CRS`.

Example:

```json
{
"rateLimit": {
"baseDelay": 1000,
"maxDelay": 60000,
"defaultBucket": "origin",
"bucketRules": [
{
"urlPattern": ".*geoserver/wms.*",
"bucket": "wmsLayer"
},
{
"urlPattern": ".*tiles.example.org/.*",
"bucket": "path"
}
]
}
}
```

Native browser image requests do not expose HTTP status or response headers to JavaScript. For WMS images loaded through native image elements, MapStore can defer requests for buckets that are already known to be rate-limited; detecting a new `Retry-After` value from the image response requires CORS or proxy visibility.

### initialState configuration

It can contain:
Expand Down
2 changes: 1 addition & 1 deletion web/client/components/map/cesium/plugins/WMSLayer.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const createLayer = (options) => {
return new GeoServerBILTerrainProvider(WMSUtils.wmsToCesiumOptionsBIL(options));
}
if (options.singleTile) {
layer = new Cesium.SingleTileImageryProvider(WMSUtils.wmsToCesiumOptionsSingleTile(options));
layer = WMSUtils.createSingleTileImageryProvider(options);
} else {
layer = new Cesium.WebMapServiceImageryProvider(WMSUtils.wmsToCesiumOptions(options));
}
Expand Down
52 changes: 52 additions & 0 deletions web/client/components/map/openlayers/__tests__/Layer-test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import axios from "../../../../libs/ajax";
import MockAdapter from "axios-mock-adapter";
import {get} from 'ol/proj';
import { getResolutions } from '../../../../utils/MapUtils';
import rateLimitManager from '../../../../utils/RateLimitManager';

let mockAxios;

Expand Down Expand Up @@ -194,6 +195,8 @@ describe('Openlayers layer', () => {

afterEach(() => {
mockAxios.restore();
rateLimitManager.reset();
ConfigUtils.setConfigProp('rateLimit', undefined);
map.setTarget(null);
document.body.innerHTML = '';
});
Expand Down Expand Up @@ -2582,6 +2585,55 @@ describe('Openlayers layer', () => {
expect(layer).toBeTruthy();
expect(layer.layer.getSource().crossOrigin).toBe("Anonymous");
});
it('delays tiled wms image load while the rate-limit bucket is blocked', (done) => {
ConfigUtils.setConfigProp('rateLimit', {
baseDelay: 1,
maxDelay: 10
});
const src = "http://sample.server/geoserver/wms?SERVICE=WMS&LAYERS=nurc:Arc_Sample&BBOX=1,2,3,4";
const options = {
type: "wms",
visibility: true,
name: "nurc:Arc_Sample",
group: "Meteo",
format: "image/png",
opacity: 1.0,
singleTile: false,
url: "http://sample.server/geoserver/wms"
};

const layer = ReactDOM.render(<OpenlayersLayer
type="wms"
options={options}
map={map}
/>, document.getElementById("container"));
let assignedSrc;
const imageElement = {
addEventListener: () => {},
set src(value) {
assignedSrc = value;
},
get src() {
return assignedSrc;
}
};
const image = {
getImage: () => imageElement,
setState: () => {}
};

rateLimitManager.register429(src, {'Retry-After': '0.001'});
layer.layer.getSource().getTileLoadFunction()(image, src);
expect(assignedSrc).toNotExist();
setTimeout(() => {
try {
expect(assignedSrc).toBe(src);
done();
} catch (e) {
done(e);
}
}, 20);
});
it('test crossOrigin is applied to single tile wms', () => {
const options = {
type: "wms",
Expand Down
175 changes: 118 additions & 57 deletions web/client/components/map/openlayers/plugins/WMSLayer.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,78 +33,139 @@ import { isValidResponse } from '../../../../utils/WMSUtils';
import { OL_VECTOR_FORMATS, applyStyle } from '../../../../utils/openlayers/VectorTileUtils';

import { proxySource, getWMSURLs, wmsToOpenlayersOptions, toOLAttributions, generateTileGrid } from '../../../../utils/openlayers/WMSUtils';
import rateLimitManager from '../../../../utils/RateLimitManager';

const failTiles = new Set(); // registry of fail tile urls to prevent reloading loops

const getRateLimitOptions = (options = {}) => ({
msRateLimitBucket: options.msRateLimitBucket,
msRateLimitKey: options.msRateLimitKey
});

const setImageError = (image) => {
if (typeof image.setState === 'function') {
image.setState(3);
} else {
image.state = 3;
if (typeof image.changed === 'function') {
image.changed();
}
}
};

const retryImageLoad = (image, src, options) => {
if (typeof image.load === 'function' && !failTiles.has(src)) {
rateLimitManager.wait(src, getRateLimitOptions(options)).then(() => image.load());
}
};

const isRateLimitError = (error) => error?.status === 429
|| error?.response?.status === 429
|| error?.originalError?.response?.status === 429;

const loadFunction = (options, headers) => function(image, src) {

if (failTiles.has(src)) { // avoids custom reload in cases of tiles that have already returned exceptions
image.setState(3);
setImageError(image);
return;
}

// fixes #3916, see https://gis.stackexchange.com/questions/175057/openlayers-3-wms-styling-using-sld-body-and-post-request
let img = image.getImage();
let newSrc = proxySource(options.forceProxy, src);
rateLimitManager.wait(src, getRateLimitOptions(options)).then(() => {
// fixes #3916, see https://gis.stackexchange.com/questions/175057/openlayers-3-wms-styling-using-sld-body-and-post-request
let img = image.getImage();
let newSrc = proxySource(options.forceProxy, src);

if (typeof window.btoa === 'function' && src.length >= (options.maxLengthUrl || getConfigProp('miscSettings')?.maxURLLength || Infinity)) {
// GET ALL THE PARAMETERS OUT OF THE SOURCE URL**
let [url, ...dataEntries] = src.split("&");
url = proxySource(options.forceProxy, url);
if (typeof window.btoa === 'function' && src.length >= (options.maxLengthUrl || getConfigProp('miscSettings')?.maxURLLength || Infinity)) {
// GET ALL THE PARAMETERS OUT OF THE SOURCE URL**
let [url, ...dataEntries] = src.split("&");
url = proxySource(options.forceProxy, url);

// SET THE PROPER HEADERS AND FINALLY SEND THE PARAMETERS
axios.post(url, "&" + dataEntries.join("&"), {
headers: {
"Content-type": "application/x-www-form-urlencoded;charset=utf-8",
...headers
},
responseType: 'arraybuffer'
}).then(response => {
if (response.status === 200) {
const uInt8Array = new Uint8Array(response.data);
let i = uInt8Array.length;
const binaryString = new Array(i);
while (i--) {
binaryString[i] = String.fromCharCode(uInt8Array[i]);
// SET THE PROPER HEADERS AND FINALLY SEND THE PARAMETERS
axios.post(url, "&" + dataEntries.join("&"), {
headers: {
"Content-type": "application/x-www-form-urlencoded;charset=utf-8",
...headers
},
responseType: 'arraybuffer',
_msRateLimitUrl: src
}).then(response => {
if (response.status === 200) {
const uInt8Array = new Uint8Array(response.data);
let i = uInt8Array.length;
const binaryString = new Array(i);
while (i--) {
binaryString[i] = String.fromCharCode(uInt8Array[i]);
}
const dataImg = binaryString.join('');
const type = response.headers['content-type'];
if (type.indexOf('image') === 0) {
img.src = 'data:' + type + ';base64,' + window.btoa(dataImg);
}
}
const dataImg = binaryString.join('');
const type = response.headers['content-type'];
if (type.indexOf('image') === 0) {
img.src = 'data:' + type + ';base64,' + window.btoa(dataImg);
}).catch(e => {
setImageError(image);
failTiles.add(src);
console.error(e);
});
} else {
if (headers) { // case of custom headers is setted in localConfig, example requestsConfigurationRules
axios.get(newSrc, {
headers,
responseType: 'blob',
_msRateLimitUrl: src
})
.then((response) => {
return response.data.type === "text/xml"
? response.data.text().then(dataText => ({...response, dataText}))
: response;
})
.then(response => {
if (isValidResponse(response)) { // not contains OGC exception
image.getImage().src = URL.createObjectURL(response.data);
} else {
throw new Error(response.dataText);
}
}).catch(errorMessage => {
image.getImage().src = null; // needed to trigger the MS imageloaderror event in Map.onLayerError
setImageError(image); // set error state for tile and removed from the queue to prevent reloading loops
failTiles.add(src); // indexing fail url tile to prevent reloading loops
console.error(errorMessage); // show ogc exception in console for debugging
});
} else {
const onDirectImageError = () => {
axios.get(newSrc, {
responseType: 'blob',
_msRateLimitUrl: src
})
.then((response) => {
return response.data.type === "text/xml"
? response.data.text().then(dataText => ({...response, dataText}))
: response;
})
.then((response) => {
if (isValidResponse(response)) {
retryImageLoad(image, src, options);
} else {
throw new Error(response.dataText);
}
})
.catch((errorMessage) => {
if (isRateLimitError(errorMessage)) {
retryImageLoad(image, src, options);
return;
}
setImageError(image);
failTiles.add(src);
console.error(errorMessage);
});
};
if (typeof img.addEventListener === 'function') {
img.addEventListener('error', onDirectImageError, { once: true });
}
img.src = newSrc;
}
}).catch(e => {
image.setState(3);
failTiles.add(src);
console.error(e);
});
} else {
if (headers) { // case of custom headers is setted in localConfig, example requestsConfigurationRules
axios.get(newSrc, {
headers,
responseType: 'blob'
})
.then((response) => {
return response.data.type === "text/xml"
? response.data.text().then(dataText => ({...response, dataText}))
: response;
})
.then(response => {
if (isValidResponse(response)) { // not contains OGC exception
image.getImage().src = URL.createObjectURL(response.data);
} else {
throw new Error(response.dataText);
}
}).catch(errorMessage => {
image.getImage().src = null; // needed to trigger the MS imageloaderror event in Map.onLayerError
image.setState(3); // set error state for tile and removed from the queue to prevent reloading loops
failTiles.add(src); // indexing fail url tile to prevent reloading loops
console.error(errorMessage); // show ogc exception in console for debugging
});
} else {
img.src = newSrc;
}
}
});
};

const createLayer = (options, map, mapId) => {
Expand Down
8 changes: 8 additions & 0 deletions web/client/configs/localConfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,14 @@
}
}
],
"rateLimit": {
"enabled": true,
"baseDelay": 1000,
"maxDelay": 60000,
"maxRetries": null,
"defaultBucket": "origin",
"bucketRules": []
},
"monitorState": [],
"userSessions": {
"enabled": true
Expand Down
Loading
Loading