From e3fa6b8d6e779fe9e4b8b34b4c3c859be5a51e7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phan=20Sainl=C3=A9ger?= Date: Tue, 21 Jul 2026 17:23:43 +0200 Subject: [PATCH] [FIX] partner_geolocalize_usability: avoid Nominatim HTTP 429 rejections The stock ``base_geocoder._call_openstreetmap`` sends a generic Odoo ``User-Agent`` shared across thousands of instances, which Nominatim rate-limits and blocks (HTTP 429 "Too many requests"), so mass geolocation fails regardless of client-side throttling. Override ``_call_openstreetmap`` to: - send a deployment-specific ``User-Agent`` read from a new system parameter ``partner_geolocalize_usability.user_agent`` (shipped with an obvious placeholder so an unconfigured instance fails loudly instead of silently reusing the banned generic agent); - throttle requests to one per second (``time.sleep``) before each call, complying with the Nominatim usage policy even when callers loop over many partners; - return ``None`` instead of raising ``IndexError`` when Nominatim returns an empty result for an unknown address. Document the required system parameter in the README and add tests covering the User-Agent selection, throttling and empty/blank results. --- partner_geolocalize_usability/README.rst | 20 +++- partner_geolocalize_usability/__manifest__.py | 3 +- .../data/ir_config_parameter.xml | 16 ++++ .../models/__init__.py | 1 + .../models/base_geocoder.py | 79 ++++++++++++++++ .../tests/__init__.py | 1 + .../tests/test_base_geocoder.py | 92 +++++++++++++++++++ 7 files changed, 210 insertions(+), 2 deletions(-) create mode 100644 partner_geolocalize_usability/data/ir_config_parameter.xml create mode 100644 partner_geolocalize_usability/models/base_geocoder.py create mode 100644 partner_geolocalize_usability/tests/__init__.py create mode 100644 partner_geolocalize_usability/tests/test_base_geocoder.py diff --git a/partner_geolocalize_usability/README.rst b/partner_geolocalize_usability/README.rst index 170e379..b47b1b6 100644 --- a/partner_geolocalize_usability/README.rst +++ b/partner_geolocalize_usability/README.rst @@ -21,7 +21,25 @@ It also modify the partner form vieww for a better understanding of the geolocat Configuration ============= -No configuration needed. +The OpenStreetMap Nominatim geocoding service requires a genuine, identifying +``User-Agent`` header with a valid contact address (see the `Nominatim usage +policy `_). The +default Odoo ``User-Agent`` is shared by many instances and gets rate-limited or +blocked (HTTP 429 "Too many requests"). + +To fix this, set the system parameter (Settings > Technical > System +Parameters): + +- **Key**: ``partner_geolocalize_usability.user_agent`` +- **Value**: a User-Agent identifying your deployment and a valid contact, + e.g. ``acme-crm/1.0 (contact: gis@acme.example)`` + +The module ships this parameter with a placeholder value; you MUST replace it +with your own, otherwise Nominatim will reject the requests. + +Requests to Nominatim are additionally throttled to one request per second to +comply with the usage policy, so mass geolocation of many partners is slow by +design (roughly one partner per second). Known issues / Roadmap ====================== diff --git a/partner_geolocalize_usability/__manifest__.py b/partner_geolocalize_usability/__manifest__.py index 20f0fe6..4ac0827 100644 --- a/partner_geolocalize_usability/__manifest__.py +++ b/partner_geolocalize_usability/__manifest__.py @@ -17,10 +17,11 @@ ], "qweb": [], "external_dependencies": { - "python": [], + "python": ["requests"], }, # always loaded "data": [ + "data/ir_config_parameter.xml", "views/res_partner.xml", ], # only loaded in demonstration mode diff --git a/partner_geolocalize_usability/data/ir_config_parameter.xml b/partner_geolocalize_usability/data/ir_config_parameter.xml new file mode 100644 index 0000000..d554af9 --- /dev/null +++ b/partner_geolocalize_usability/data/ir_config_parameter.xml @@ -0,0 +1,16 @@ + + + + + partner_geolocalize_usability.user_agent + SET-ME partner_geolocalize_usability (contact: you@example.invalid) + + diff --git a/partner_geolocalize_usability/models/__init__.py b/partner_geolocalize_usability/models/__init__.py index 608407e..d95c99a 100644 --- a/partner_geolocalize_usability/models/__init__.py +++ b/partner_geolocalize_usability/models/__init__.py @@ -1,2 +1,3 @@ +from . import base_geocoder from . import res_partner diff --git a/partner_geolocalize_usability/models/base_geocoder.py b/partner_geolocalize_usability/models/base_geocoder.py new file mode 100644 index 0000000..fdbfcaa --- /dev/null +++ b/partner_geolocalize_usability/models/base_geocoder.py @@ -0,0 +1,79 @@ +import logging +import time + +import requests + +from odoo import api, models + +_logger = logging.getLogger(__name__) + +# System parameter holding the User-Agent sent to Nominatim. +# Nominatim's usage policy requires a genuine, identifying User-Agent with a +# valid contact. The stock Odoo User-Agent is shared by thousands of instances +# and is rate-limited/blocked (HTTP 429), so we let each deployment set its own. +USER_AGENT_PARAM = "partner_geolocalize_usability.user_agent" + +# Placeholder shown until the deployment sets a real value. It intentionally +# does NOT identify any real service so an unconfigured instance fails loudly +# rather than silently reusing a banned generic agent. +USER_AGENT_PLACEHOLDER = ( + "SET-ME partner_geolocalize_usability (contact: you@example.invalid)" +) + +# Nominatim's public API allows at most 1 request per second. We wait slightly +# more than a second between calls to stay safely under the limit. +NOMINATIM_MIN_INTERVAL = 1.1 + + +class GeoCoder(models.AbstractModel): + _inherit = "base.geocoder" + + def _get_nominatim_user_agent(self): + """Return the configured User-Agent for Nominatim requests. + + Falls back to an obvious placeholder so a misconfigured instance is easy + to spot in logs (and so we never silently reuse Odoo's banned agent). + """ + return ( + self.env["ir.config_parameter"] + .sudo() + .get_param(USER_AGENT_PARAM, USER_AGENT_PLACEHOLDER) + ) + + @api.model + def _call_openstreetmap(self, addr, **kw): + """Query Nominatim with a configurable User-Agent and rate limiting. + + Overrides the stock implementation to fix two problems that cause + HTTP 429 ("Too many requests"): + + * the shared/generic Odoo User-Agent is blocked by Nominatim -> use a + deployment-specific one from a system parameter; + * bursts of requests exceed the 1 req/s policy -> throttle between calls. + """ + if not addr: + _logger.info("invalid address given") + return None + url = "https://nominatim.openstreetmap.org/search" + try: + headers = {"User-Agent": self._get_nominatim_user_agent()} + # Respect Nominatim's 1 req/s policy. Sleeping before the call keeps + # the throttle effective even when callers loop over many records. + time.sleep(NOMINATIM_MIN_INTERVAL) + response = requests.get( + url, headers=headers, params={"format": "json", "q": addr} + ) + _logger.info("openstreetmap nominatim service called") + if response.status_code != 200: + _logger.warning( + "Request to openstreetmap failed.\nCode: %s\nContent: %s", + response.status_code, + response.content, + ) + result = response.json() + except Exception as e: + self._raise_query_error(e) + if not result: + return None + geo = result[0] + return float(geo["lat"]), float(geo["lon"]) diff --git a/partner_geolocalize_usability/tests/__init__.py b/partner_geolocalize_usability/tests/__init__.py new file mode 100644 index 0000000..10fe9c8 --- /dev/null +++ b/partner_geolocalize_usability/tests/__init__.py @@ -0,0 +1 @@ +from . import test_base_geocoder diff --git a/partner_geolocalize_usability/tests/test_base_geocoder.py b/partner_geolocalize_usability/tests/test_base_geocoder.py new file mode 100644 index 0000000..96e140c --- /dev/null +++ b/partner_geolocalize_usability/tests/test_base_geocoder.py @@ -0,0 +1,92 @@ +from unittest.mock import MagicMock, patch + +from odoo.tests.common import TransactionCase + +from ..models.base_geocoder import ( + NOMINATIM_MIN_INTERVAL, + USER_AGENT_PARAM, + USER_AGENT_PLACEHOLDER, +) + +_MODULE = "odoo.addons.partner_geolocalize_usability.models.base_geocoder" + + +def _fake_response(payload, status_code=200): + response = MagicMock() + response.status_code = status_code + response.json.return_value = payload + return response + + +class TestBaseGeocoder(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.geocoder = cls.env["base.geocoder"] + cls.Param = cls.env["ir.config_parameter"].sudo() + + def test_uses_configured_user_agent(self): + # A deployment-specific User-Agent must be sent to Nominatim. + self.Param.set_param(USER_AGENT_PARAM, "acme/1.0 (contact: x@acme.test)") + + with patch(f"{_MODULE}.time.sleep"), patch( + f"{_MODULE}.requests.get", + return_value=_fake_response([{"lat": "1.5", "lon": "2.5"}]), + ) as mocked_get: + result = self.geocoder._call_openstreetmap("some address") + + self.assertEqual(result, (1.5, 2.5)) + _, kwargs = mocked_get.call_args + self.assertEqual( + kwargs["headers"]["User-Agent"], "acme/1.0 (contact: x@acme.test)" + ) + + def test_falls_back_to_placeholder_user_agent(self): + # Without configuration, the obvious placeholder is used (never Odoo's + # banned generic agent). + self.Param.set_param(USER_AGENT_PARAM, False) + + with patch(f"{_MODULE}.time.sleep"), patch( + f"{_MODULE}.requests.get", + return_value=_fake_response([{"lat": "1.0", "lon": "2.0"}]), + ) as mocked_get: + self.geocoder._call_openstreetmap("addr") + + _, kwargs = mocked_get.call_args + self.assertEqual( + kwargs["headers"]["User-Agent"], USER_AGENT_PLACEHOLDER + ) + + def test_throttles_before_request(self): + # The 1 req/s policy is enforced by sleeping before each call. + self.Param.set_param(USER_AGENT_PARAM, "acme/1.0 (contact: x@acme.test)") + + with patch(f"{_MODULE}.time.sleep") as mocked_sleep, patch( + f"{_MODULE}.requests.get", + return_value=_fake_response([{"lat": "1.0", "lon": "2.0"}]), + ): + self.geocoder._call_openstreetmap("addr") + + mocked_sleep.assert_called_once_with(NOMINATIM_MIN_INTERVAL) + + def test_empty_result_returns_none(self): + # An unknown address (empty Nominatim result) must not raise IndexError. + self.Param.set_param(USER_AGENT_PARAM, "acme/1.0 (contact: x@acme.test)") + + with patch(f"{_MODULE}.time.sleep"), patch( + f"{_MODULE}.requests.get", return_value=_fake_response([]) + ): + result = self.geocoder._call_openstreetmap("nowhere at all") + + self.assertIsNone(result) + + def test_blank_address_returns_none_without_request(self): + # Empty address short-circuits: no HTTP call, no throttle. + with patch(f"{_MODULE}.time.sleep") as mocked_sleep, patch( + f"{_MODULE}.requests.get" + ) as mocked_get: + result = self.geocoder._call_openstreetmap("") + + self.assertIsNone(result) + mocked_get.assert_not_called() + mocked_sleep.assert_not_called()