Harden Nominatim country resolution fallback

This commit is contained in:
trifonovt 2026-06-17 12:20:56 +02:00
parent c91d3cc1c4
commit 7584bb8578
3 changed files with 182 additions and 29 deletions

View File

@ -25,6 +25,7 @@ import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicLong;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
/** /**
@ -49,6 +50,7 @@ public class NominatimGeoCountryResolver implements GeoCountryResolver {
private final AtomicLong nextRemoteRequestNanos = new AtomicLong(0L); private final AtomicLong nextRemoteRequestNanos = new AtomicLong(0L);
private final Object requestGate = new Object(); private final Object requestGate = new Object();
@Autowired
public NominatimGeoCountryResolver( public NominatimGeoCountryResolver(
EventHubProperties properties, EventHubProperties properties,
ObjectMapper objectMapper ObjectMapper objectMapper
@ -165,18 +167,18 @@ public class NominatimGeoCountryResolver implements GeoCountryResolver {
try { try {
synchronized (requestGate) { synchronized (requestGate) {
awaitRequestPermit(effectiveMinimumRequestInterval(config)); awaitRequestPermit(effectiveMinimumRequestInterval(config));
URI uri = reverseUri(latitude, longitude, config); URI uri = reverseUri(latitude, longitude, config, false);
HttpRequest request = HttpRequest.newBuilder(uri) HttpResponse<String> response = sendRequest(uri, config, true);
.timeout(config.getReadTimeout()) if (response.statusCode() == 406) {
.header("Accept", "application/json") URI compatibilityUri = reverseUri(latitude, longitude, config, true);
.header("Accept-Language", config.getAcceptLanguage()) LOG.info(
.header("User-Agent", config.getUserAgent()) "Nominatim endpoint rejected the standard request with HTTP 406 for {}. "
.GET() + "Retrying once with a legacy-compatible request matching JsonNominatimClient.",
.build(); uri.getHost()
HttpResponse<String> response = httpClient.send(
request,
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)
); );
awaitRequestPermit(effectiveMinimumRequestInterval(config));
response = sendCompatibilityRequest(compatibilityUri, config);
}
if (response.statusCode() == 404) { if (response.statusCode() == 404) {
return result( return result(
GeoCountryResolutionStatus.NOT_FOUND, GeoCountryResolutionStatus.NOT_FOUND,
@ -191,6 +193,14 @@ public class NominatimGeoCountryResolver implements GeoCountryResolver {
); );
} }
if (response.statusCode() / 100 != 2) { if (response.statusCode() / 100 != 2) {
String responseSummary = responseBodySummary(response.body());
LOG.warn(
"Nominatim reverse lookup failed for {},{} with HTTP status {} and response body: {}",
latitude,
longitude,
response.statusCode(),
responseSummary
);
return result( return result(
GeoCountryResolutionStatus.ERROR, GeoCountryResolutionStatus.ERROR,
latitude, latitude,
@ -200,7 +210,8 @@ public class NominatimGeoCountryResolver implements GeoCountryResolver {
null, null,
false, false,
true, true,
"Nominatim reverse lookup failed with HTTP status " + response.statusCode() + "." "Nominatim reverse lookup failed with HTTP status " + response.statusCode()
+ (responseSummary == null ? "." : ": " + responseSummary)
); );
} }
@ -277,25 +288,98 @@ public class NominatimGeoCountryResolver implements GeoCountryResolver {
} }
} }
private HttpResponse<String> sendRequest(
URI uri,
EventHubProperties.Nominatim config,
boolean includeContentNegotiationHeaders
) throws IOException, InterruptedException {
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(uri)
.version(HttpClient.Version.HTTP_1_1)
.timeout(config.getReadTimeout())
.GET();
if (config.getUserAgent() != null && !config.getUserAgent().isBlank()) {
requestBuilder.header("User-Agent", config.getUserAgent());
}
if (includeContentNegotiationHeaders) {
requestBuilder.header("Accept", "application/json");
if (config.getAcceptLanguage() != null && !config.getAcceptLanguage().isBlank()) {
requestBuilder.header("Accept-Language", config.getAcceptLanguage());
}
}
return httpClient.send(
requestBuilder.build(),
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)
);
}
/**
* Sends the same kind of plain HTTP GET used by the previously working
* fr.dudie JsonNominatimClient: no explicit Accept, Accept-Language or
* User-Agent headers and only legacy-compatible reverse parameters.
*/
private HttpResponse<String> sendCompatibilityRequest(
URI uri,
EventHubProperties.Nominatim config
) throws IOException, InterruptedException {
HttpRequest request = HttpRequest.newBuilder(uri)
.version(HttpClient.Version.HTTP_1_1)
.timeout(config.getReadTimeout())
.header("User-Agent", "")
.GET()
.build();
return httpClient.send(
request,
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)
);
}
private String responseBodySummary(String body) {
if (body == null || body.isBlank()) {
return null;
}
String normalized = body.replaceAll("\\s+", " ").trim();
return normalized.length() <= 500 ? normalized : normalized.substring(0, 500) + "...";
}
private URI reverseUri( private URI reverseUri(
BigDecimal latitude, BigDecimal latitude,
BigDecimal longitude, BigDecimal longitude,
EventHubProperties.Nominatim config EventHubProperties.Nominatim config,
boolean legacyCompatible
) { ) {
StringBuilder query = new StringBuilder(config.getBaseUrl()) StringBuilder query = new StringBuilder(normalizedBaseUrl(config.getBaseUrl()))
.append("/reverse?format=jsonv2") .append("/reverse?format=jsonv2");
.append("&lat=").append(url(latitude.toPlainString()))
.append("&lon=").append(url(longitude.toPlainString())) // JsonNominatimClient puts the identifying email directly into the URL.
.append("&zoom=3")
.append("&addressdetails=1")
.append("&layer=address")
.append("&accept-language=").append(url(config.getAcceptLanguage()));
if (config.getEmail() != null && !config.getEmail().isBlank()) { if (config.getEmail() != null && !config.getEmail().isBlank()) {
query.append("&email=").append(url(config.getEmail())); query.append("&email=").append(url(config.getEmail()));
} }
query.append("&lat=").append(url(latitude.toPlainString()))
.append("&lon=").append(url(longitude.toPlainString()))
.append("&zoom=3");
if (!legacyCompatible) {
query.append("&addressdetails=1")
.append("&layer=address");
if (config.getAcceptLanguage() != null && !config.getAcceptLanguage().isBlank()) {
query.append("&accept-language=").append(url(config.getAcceptLanguage()));
}
}
return URI.create(query.toString()); return URI.create(query.toString());
} }
private String normalizedBaseUrl(String baseUrl) {
if (baseUrl == null || baseUrl.isBlank()) {
throw new IllegalArgumentException("Nominatim base URL must not be blank.");
}
return baseUrl.endsWith("/")
? baseUrl.substring(0, baseUrl.length() - 1)
: baseUrl;
}
private Duration effectiveMinimumRequestInterval(EventHubProperties.Nominatim config) { private Duration effectiveMinimumRequestInterval(EventHubProperties.Nominatim config) {
Duration configured = config.getMinimumRequestInterval() == null Duration configured = config.getMinimumRequestInterval() == null
? Duration.ZERO ? Duration.ZERO

View File

@ -56,19 +56,19 @@ eventhub:
nominatim: nominatim:
# Public OSM Nominatim requires an identifying User-Agent and no more than one request/second. # Public OSM Nominatim requires an identifying User-Agent and no more than one request/second.
# Configure a self-hosted endpoint here when higher throughput is required. # Configure a self-hosted endpoint here when higher throughput is required.
base-url: ${NOMINATIM_BASE_URL:https://nominatim.openstreetmap.org} base-url: ${NOMINATIM_BASE_URL:http://nominatim.iothings.at:7070}
# Deliberate opt-in is required for the donated public service. Prefer a self-hosted or contracted endpoint for production/bulk processing. # Deliberate opt-in is required for the donated public service. Prefer a self-hosted or contracted endpoint for production/bulk processing.
public-service-enabled: ${NOMINATIM_PUBLIC_SERVICE_ENABLED:false} public-service-enabled: ${NOMINATIM_PUBLIC_SERVICE_ENABLED:false}
user-agent: ${NOMINATIM_USER_AGENT:eventhub-tachograph/0.1 (Nominatim reverse geocoding)} user-agent: ${NOMINATIM_USER_AGENT:eventhub-tachograph/0.1 (Nominatim reverse geocoding)}
email: ${NOMINATIM_EMAIL:} email: ${NOMINATIM_EMAIL:martin.schweitzer@procon.co.at}
accept-language: ${NOMINATIM_ACCEPT_LANGUAGE:en} accept-language: ${NOMINATIM_ACCEPT_LANGUAGE:en}
connect-timeout: ${NOMINATIM_CONNECT_TIMEOUT:10s} connect-timeout: ${NOMINATIM_CONNECT_TIMEOUT:5s}
read-timeout: ${NOMINATIM_READ_TIMEOUT:20s} read-timeout: ${NOMINATIM_READ_TIMEOUT:30s}
minimum-request-interval: ${NOMINATIM_MIN_REQUEST_INTERVAL:1s} minimum-request-interval: ${NOMINATIM_MIN_REQUEST_INTERVAL:0s}
cache-ttl: ${NOMINATIM_CACHE_TTL:30d} cache-ttl: ${NOMINATIM_CACHE_TTL:30d}
cache-max-entries: ${NOMINATIM_CACHE_MAX_ENTRIES:100000} cache-max-entries: ${NOMINATIM_CACHE_MAX_ENTRIES:100000}
coordinate-decimal-places: ${NOMINATIM_COORDINATE_DECIMAL_PLACES:4} coordinate-decimal-places: ${NOMINATIM_COORDINATE_DECIMAL_PLACES:3}
max-remote-lookups-per-execution: ${NOMINATIM_MAX_REMOTE_LOOKUPS_PER_EXECUTION:25} max-remote-lookups-per-execution: ${NOMINATIM_MAX_REMOTE_LOOKUPS_PER_EXECUTION:100000}
tachograph: tachograph:
default-chunk-days: 1 default-chunk-days: 1

View File

@ -81,6 +81,75 @@ class NominatimGeoCountryResolverTest {
.contains("lon=16.373800"); .contains("lon=16.373800");
} }
@Test
void retriesWithoutContentNegotiationHeadersWhenEndpointReturns406() throws Exception {
AtomicInteger requestCount = new AtomicInteger();
AtomicReference<String> firstAccept = new AtomicReference<>();
AtomicReference<String> secondAccept = new AtomicReference<>();
AtomicReference<String> secondAcceptLanguage = new AtomicReference<>();
AtomicReference<String> secondUserAgent = new AtomicReference<>();
AtomicReference<String> secondQuery = new AtomicReference<>();
server = HttpServer.create(new InetSocketAddress(0), 0);
server.createContext("/reverse", exchange -> {
int currentRequest = requestCount.incrementAndGet();
if (currentRequest == 1) {
firstAccept.set(exchange.getRequestHeaders().getFirst("Accept"));
byte[] body = "Not acceptable".getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(406, body.length);
exchange.getResponseBody().write(body);
exchange.close();
return;
}
secondAccept.set(exchange.getRequestHeaders().getFirst("Accept"));
secondAcceptLanguage.set(exchange.getRequestHeaders().getFirst("Accept-Language"));
secondUserAgent.set(exchange.getRequestHeaders().getFirst("User-Agent"));
secondQuery.set(exchange.getRequestURI().getRawQuery());
byte[] body = ("{\"place_id\":1,\"display_name\":\"Vienna, Austria\","
+ "\"address\":{\"country\":\"Austria\",\"country_code\":\"at\"}}")
.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().add("Content-Type", "application/json");
exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body);
exchange.close();
});
server.start();
EventHubProperties properties = new EventHubProperties();
EventHubProperties.Nominatim config = properties.getReverseGeocoding().getNominatim();
config.setBaseUrl("http://localhost:" + server.getAddress().getPort());
config.setUserAgent("eventhub-test/1.0");
config.setEmail("email@address");
config.setMinimumRequestInterval(Duration.ZERO);
NominatimGeoCountryResolver resolver = new NominatimGeoCountryResolver(
properties,
new ObjectMapper(),
HttpClient.newHttpClient(),
Clock.systemUTC()
);
GeoCountryResolution result = resolver.resolve(
new BigDecimal("48.2082"),
new BigDecimal("16.3738"),
true
);
assertThat(result.resolved()).isTrue();
assertThat(result.countryCode()).isEqualTo("AT");
assertThat(requestCount).hasValue(2);
assertThat(firstAccept).hasValue("application/json");
assertThat(secondAccept.get()).isNull();
assertThat(secondAcceptLanguage.get()).isNull();
assertThat(secondUserAgent).hasValue("");
assertThat(secondQuery.get())
.contains("format=jsonv2")
.contains("email=email%40address")
.contains("zoom=3")
.doesNotContain("layer=")
.doesNotContain("accept-language=")
.doesNotContain("addressdetails=");
}
@Test @Test
void requiresExplicitOptInForPublicOsmEndpoint() { void requiresExplicitOptInForPublicOsmEndpoint() {
EventHubProperties properties = new EventHubProperties(); EventHubProperties properties = new EventHubProperties();