Repository: geopy/geopy
Pull request #418 (2020-06-21 18:12:21)

geopy 2.0: Convert `_call_geocoder` to callback style
(This PR contains a subset of commits from #335).

This would allow to add an optional asyncio support on the Adapters level transparently to Geocoders.

All geocoders except ArcGIS always perform exacly 1 request per method call, so the conversion is rather simple.


Patch:
diff --git a/geopy/geocoders/algolia.py b/geopy/geocoders/algolia.py
index fb3e5a4..196c5c9 100644
--- a/geopy/geocoders/algolia.py
+++ b/geopy/geocoders/algolia.py
@@ -1,4 +1,5 @@
 import collections.abc
+from functools import partial
 from urllib.parse import urlencode
 
 from geopy.geocoders.base import DEFAULT_SENTINEL, Geocoder
@@ -201,11 +202,8 @@ class AlgoliaPlaces(Geocoder):
             headers['X-Algolia-API-Key'] = self.api_key
 
         logger.debug('%s.geocode: %s', self.__class__.__name__, url)
-        return self._parse_json(
-            self._call_geocoder(url, headers=headers, timeout=timeout),
-            exactly_one,
-            language=language,
-        )
+        callback = partial(self._parse_json, exactly_one=exactly_one, language=language)
+        return self._call_geocoder(url, callback, headers=headers, timeout=timeout)
 
     def reverse(
             self,
@@ -264,11 +262,8 @@ class AlgoliaPlaces(Geocoder):
             headers['X-Algolia-API-Key'] = self.api_key
 
         logger.debug("%s.reverse: %s", self.__class__.__name__, url)
-        return self._parse_json(
-            self._call_geocoder(url, headers=headers, timeout=timeout),
-            exactly_one,
-            language=language,
-        )
+        callback = partial(self._parse_json, exactly_one=exactly_one, language=language)
+        return self._call_geocoder(url, callback, headers=headers, timeout=timeout)
 
     def _parse_feature(self, feature, language):
         # Parse each resource.
diff --git a/geopy/geocoders/arcgis.py b/geopy/geocoders/arcgis.py
index 861aa7b..f1e771b 100644
--- a/geopy/geocoders/arcgis.py
+++ b/geopy/geocoders/arcgis.py
@@ -1,4 +1,5 @@
 import json
+from functools import partial
 from time import time
 from urllib.parse import urlencode
 
@@ -7,7 +8,7 @@ from geopy.exc import (
     GeocoderAuthenticationFailure,
     GeocoderServiceError,
 )
-from geopy.geocoders.base import DEFAULT_SENTINEL, Geocoder
+from geopy.geocoders.base import DEFAULT_SENTINEL, Geocoder, _synchronized
 from geopy.location import Location
 from geopy.util import logger
 
@@ -24,7 +25,6 @@ class ArcGIS(Geocoder):
     """
 
     _TOKEN_EXPIRED = 498
-    _MAX_RETRIES = 3
 
     auth_path = '/sharing/generateToken'
     geocode_path = '/arcgis/rest/services/World/GeocodeServer/findAddressCandidates'
@@ -110,8 +110,6 @@ class ArcGIS(Geocoder):
                 raise ConfigurationError(
                     "Authenticated mode requires scheme of 'https'"
                 )
-            self._base_call_geocoder = self._call_geocoder
-            self._call_geocoder = self._authenticated_call_geocoder
 
         self.username = username
         self.password = password
@@ -121,10 +119,7 @@ class ArcGIS(Geocoder):
             '%s://%s%s' % (self.scheme, self.auth_domain, self.auth_path)
         )
 
-        self.token = None
         self.token_lifetime = token_lifetime * 60  # store in seconds
-        self.token_expiry = None
-        self.retry = 1
 
         self.domain = domain.strip('/')
         self.api = (
@@ -134,15 +129,9 @@ class ArcGIS(Geocoder):
             '%s://%s%s' % (self.scheme, self.domain, self.reverse_path)
         )
 
-    def _authenticated_call_geocoder(self, url, timeout=DEFAULT_SENTINEL):
-        """
-        Wrap self._call_geocoder, handling tokens.
-        """
-        if self.token is None or int(time()) > self.token_expiry:
-            self._refresh_authentication_token()
-        url = "&".join((url, urlencode({"token": self.token})))
-        headers = {"Referer": self.referer}
-        return self._base_call_geocoder(url, timeout=timeout, headers=headers)
+        # Mutable state
+        self.token = None
+        self.token_expiry = None
 
     def geocode(self, query, *, exactly_one=True, timeout=DEFAULT_SENTINEL,
                 out_fields=None):
@@ -180,16 +169,11 @@ class ArcGIS(Geocoder):
                 params['outFields'] = ",".join(out_fields)
         url = "?".join((self.api, urlencode(params)))
         logger.debug("%s.geocode: %s", self.__class__.__name__, url)
-        response = self._call_geocoder(url, timeout=timeout)
+        callback = partial(self._parse_geocode, exactly_one=exactly_one)
+        return self._authenticated_call_geocoder(url, callback, timeout=timeout)
 
-        # Handle any errors; recursing in the case of an expired token.
+    def _parse_geocode(self, response, exactly_one):
         if 'error' in response:
-            if response['error']['code'] == self._TOKEN_EXPIRED:
-                self.retry += 1
-                self._refresh_authentication_token()
-                return self.geocode(
-                    query, exactly_one=exactly_one, timeout=timeout
-                )
             raise GeocoderServiceError(str(response['error']))
 
         # Success; convert from the ArcGIS JSON format.
@@ -239,16 +223,13 @@ class ArcGIS(Geocoder):
             params['distance'] = distance
         url = "?".join((self.reverse_api, urlencode(params)))
         logger.debug("%s.reverse: %s", self.__class__.__name__, url)
-        response = self._call_geocoder(url, timeout=timeout)
+        callback = partial(self._parse_reverse, exactly_one=exactly_one)
+        return self._authenticated_call_geocoder(url, callback, timeout=timeout)
+
+    def _parse_reverse(self, response, exactly_one):
         if not len(response):
             return None
         if 'error' in response:
-            if response['error']['code'] == self._TOKEN_EXPIRED:
-                self.retry += 1
-                self._refresh_authentication_token()
-                return self.reverse(query, exactly_one=exactly_one,
-                                    timeout=timeout, distance=distance,
-                                    wkid=wkid)
             # https://developers.arcgis.com/rest/geocode/api-reference/geocoding-service-output.htm
             if response['error']['code'] == 400:
                 # 'details': ['Unable to find address for the specified location.']}
@@ -272,14 +253,50 @@ class ArcGIS(Geocoder):
         else:
             return [location]
 
-    def _refresh_authentication_token(self):
-        """
-        POST to ArcGIS requesting a new token.
-        """
-        if self.retry == self._MAX_RETRIES:
-            raise GeocoderAuthenticationFailure(
-                'Too many retries for auth: %s' % self.retry
+    def _authenticated_call_geocoder(
+        self, url, parse_callback, *, timeout=DEFAULT_SENTINEL
+    ):
+        if not self.username:
+            return self._call_geocoder(url, parse_callback, timeout=timeout)
+
+        def query_callback():
+            call_url = "&".join((url, urlencode({"token": self.token})))
+            headers = {"Referer": self.referer}
+            return self._call_geocoder(
+                call_url,
+                partial(maybe_reauthenticate_callback, from_token=self.token),
+                timeout=timeout,
+                headers=headers,
             )
+
+        def maybe_reauthenticate_callback(response, *, from_token):
+            if "error" in response:
+                if response["error"]["code"] == self._TOKEN_EXPIRED:
+                    return self._refresh_authentication_token(
+                        query_retry_callback, timeout=timeout, from_token=from_token
+                    )
+            return parse_callback(response)
+
+        def query_retry_callback():
+            call_url = "&".join((url, urlencode({"token": self.token})))
+            headers = {"Referer": self.referer}
+            return self._call_geocoder(
+                call_url, parse_callback, timeout=timeout, headers=headers
+            )
+
+        if self.token is None or int(time()) > self.token_expiry:
+            return self._refresh_authentication_token(
+                query_callback, timeout=timeout, from_token=self.token
+            )
+        else:
+            return query_callback()
+
+    @_synchronized
+    def _refresh_authentication_token(self, callback_success, *, timeout, from_token):
+        if from_token != self.token:
+            # Token has already been updated by a concurrent call.
+            return callback_success()
+
         token_request_arguments = {
             'username': self.username,
             'password': self.password,
@@ -292,13 +309,15 @@ class ArcGIS(Geocoder):
             "%s._refresh_authentication_token: %s",
             self.__class__.__name__, url
         )
-        self.token_expiry = int(time()) + self.token_lifetime
-        response = self._base_call_geocoder(url)
-        if 'token' not in response:
-            raise GeocoderAuthenticationFailure(
-                'Missing token in auth request.'
-                'Request URL: %s; response JSON: %s' %
-                (url, json.dumps(response))
-            )
-        self.retry = 0
-        self.token = response['token']
+
+        def cb(response):
+            if "token" not in response:
+                raise GeocoderAuthenticationFailure(
+                    "Missing token in auth request."
+                    "Request URL: %s; response JSON: %s" % (url, json.dumps(response))
+                )
+            self.token = response["token"]
+            self.token_expiry = int(time()) + self.token_lifetime
+            return callback_success()
+
+        return self._call_geocoder(url, cb, timeout=timeout)
diff --git a/geopy/geocoders/baidu.py b/geopy/geocoders/baidu.py
index 043cb16..66b97c2 100644
--- a/geopy/geocoders/baidu.py
+++ b/geopy/geocoders/baidu.py
@@ -1,4 +1,5 @@
 import hashlib
+from functools import partial
 from urllib.parse import quote_plus, urlencode
 
 from geopy.exc import (
@@ -125,9 +126,8 @@ class Baidu(Geocoder):
         url = self._construct_url(self.api, self.api_path, params)
 
         logger.debug("%s.geocode: %s", self.__class__.__name__, url)
-        return self._parse_json(
-            self._call_geocoder(url, timeout=timeout), exactly_one=exactly_one
-        )
+        callback = partial(self._parse_json, exactly_one=exactly_one)
+        return self._call_geocoder(url, callback, timeout=timeout)
 
     def reverse(self, query, *, exactly_one=True, timeout=DEFAULT_SENTINEL):
         """
@@ -159,9 +159,8 @@ class Baidu(Geocoder):
         url = self._construct_url(self.reverse_api, self.reverse_path, params)
 
         logger.debug("%s.reverse: %s", self.__class__.__name__, url)
-        return self._parse_reverse_json(
-            self._call_geocoder(url, timeout=timeout), exactly_one=exactly_one
-        )
+        callback = partial(self._parse_reverse_json, exactly_one=exactly_one)
+        return self._call_geocoder(url, callback, timeout=timeout)
 
     def _parse_reverse_json(self, page, exactly_one=True):
         """
diff --git a/geopy/geocoders/banfrance.py b/geopy/geocoders/banfrance.py
index 5a337e1..bd5227c 100644
--- a/geopy/geocoders/banfrance.py
+++ b/geopy/geocoders/banfrance.py
@@ -1,3 +1,4 @@
+from functools import partial
 from urllib.parse import urlencode
 
 from geopy.geocoders.base import DEFAULT_SENTINEL, Geocoder
@@ -113,9 +114,8 @@ class BANFrance(Geocoder):
         url = "?".join((self.geocode_api, urlencode(params)))
 
         logger.debug("%s.geocode: %s", self.__class__.__name__, url)
-        return self._parse_json(
-            self._call_geocoder(url, timeout=timeout), exactly_one
-        )
+        callback = partial(self._parse_json, exactly_one=exactly_one)
+        return self._call_geocoder(url, callback, timeout=timeout)
 
     def reverse(
             self,
@@ -157,9 +157,8 @@ class BANFrance(Geocoder):
 
         url = "?".join((self.reverse_api, urlencode(params)))
         logger.debug("%s.reverse: %s", self.__class__.__name__, url)
-        return self._parse_json(
-            self._call_geocoder(url, timeout=timeout), exactly_one
-        )
+        callback = partial(self._parse_json, exactly_one=exactly_one)
+        return self._call_geocoder(url, callback, timeout=timeout)
 
     def _parse_feature(self, feature):
         # Parse each resource.
diff --git a/geopy/geocoders/base.py b/geopy/geocoders/base.py
index e2bc206..66115b0 100644
--- a/geopy/geocoders/base.py
+++ b/geopy/geocoders/base.py
@@ -1,3 +1,6 @@
+import functools
+import threading
+
 from geopy.adapters import AdapterHTTPError, RequestsAdapter, URLLibAdapter
 from geopy.exc import (
     ConfigurationError,
@@ -284,6 +287,7 @@ class Geocoder:
     def _call_geocoder(
             self,
             url,
+            callback,
             *,
             timeout=DEFAULT_SENTINEL,
             is_json=True,
@@ -302,9 +306,10 @@ class Geocoder:
 
         try:
             if is_json:
-                return self.adapter.get_json(url, timeout=timeout, headers=req_headers)
+                result = self.adapter.get_json(url, timeout=timeout, headers=req_headers)
             else:
-                return self.adapter.get_text(url, timeout=timeout, headers=req_headers)
+                result = self.adapter.get_text(url, timeout=timeout, headers=req_headers)
+            return callback(result)
         except AdapterHTTPError as error:
             if error.text:
                 logger.info(
@@ -325,3 +330,19 @@ class Geocoder:
 
     # def reverse(self, query, *, exactly_one=True, timeout=DEFAULT_SENTINEL):
     #     raise NotImplementedError()
+
+
+def _synchronized(func):
+    """A decorator for geocoder methods which makes the method always run
+    under a lock. The lock is reentrant.
+
+    This decorator transparently handles sync and async working modes.
+    """
+    lock = threading.RLock()
+
+    @functools.wraps(func)
+    def f(self, *args, **kwargs):
+        with lock:
+            return func(self, *args, **kwargs)
+
+    return f
diff --git a/geopy/geocoders/bing.py b/geopy/geocoders/bing.py
index dbc5c2c..ef74b93 100644
--- a/geopy/geocoders/bing.py
+++ b/geopy/geocoders/bing.py
@@ -1,4 +1,5 @@
 import collections.abc
+from functools import partial
 from urllib.parse import quote, urlencode
 
 from geopy.exc import (
@@ -156,10 +157,8 @@ class Bing(Geocoder):
 
         url = "?".join((self.geocode_api, urlencode(params)))
         logger.debug("%s.geocode: %s", self.__class__.__name__, url)
-        return self._parse_json(
-            self._call_geocoder(url, timeout=timeout),
-            exactly_one
-        )
+        callback = partial(self._parse_json, exactly_one=exactly_one)
+        return self._call_geocoder(url, callback, timeout=timeout)
 
     def reverse(
             self,
@@ -208,10 +207,8 @@ class Bing(Geocoder):
                         urlencode(params)))
 
         logger.debug("%s.reverse: %s", self.__class__.__name__, url)
-        return self._parse_json(
-            self._call_geocoder(url, timeout=timeout),
-            exactly_one
-        )
+        callback = partial(self._parse_json, exactly_one=exactly_one)
+        return self._call_geocoder(url, callback, timeout=timeout)
 
     def _parse_json(self, doc, exactly_one=True):
         """
diff --git a/geopy/geocoders/databc.py b/geopy/geocoders/databc.py
index 0393a7f..0402d86 100644
--- a/geopy/geocoders/databc.py
+++ b/geopy/geocoders/databc.py
@@ -1,3 +1,4 @@
+from functools import partial
 from urllib.parse import urlencode
 
 from geopy.exc import GeocoderQueryError
@@ -119,8 +120,10 @@ class DataBC(Geocoder):
 
         url = "?".join((self.api, urlencode(params)))
         logger.debug("%s.geocode: %s", self.__class__.__name__, url)
-        response = self._call_geocoder(url, timeout=timeout)
+        callback = partial(self._parse_json, exactly_one=exactly_one)
+        return self._call_geocoder(url, callback, timeout=timeout)
 
+    def _parse_json(self, response, exactly_one):
         # Success; convert from GeoJSON
         if not len(response['features']):
             return None
diff --git a/geopy/geocoders/geocodefarm.py b/geopy/geocoders/geocodefarm.py
index 2c9ed02..601a7a5 100644
--- a/geopy/geocoders/geocodefarm.py
+++ b/geopy/geocoders/geocodefarm.py
@@ -1,3 +1,4 @@
+from functools import partial
 from urllib.parse import urlencode
 
 from geopy.exc import (
@@ -100,9 +101,8 @@ class GeocodeFarm(Geocoder):
             params['key'] = self.api_key
         url = "?".join((self.api, urlencode(params)))
         logger.debug("%s.geocode: %s", self.__class__.__name__, url)
-        return self._parse_json(
-            self._call_geocoder(url, timeout=timeout), exactly_one
-        )
+        callback = partial(self._parse_json, exactly_one=exactly_one)
+        return self._call_geocoder(url, callback, timeout=timeout)
 
     def reverse(self, query, *, exactly_one=True, timeout=DEFAULT_SENTINEL):
         """
@@ -137,9 +137,8 @@ class GeocodeFarm(Geocoder):
             params['key'] = self.api_key
         url = "?".join((self.reverse_api, urlencode(params)))
         logger.debug("%s.reverse: %s", self.__class__.__name__, url)
-        return self._parse_json(
-            self._call_geocoder(url, timeout=timeout), exactly_one
-        )
+        callback = partial(self._parse_json, exactly_one=exactly_one)
+        return self._call_geocoder(url, callback, timeout=timeout)
 
     def _parse_code(self, results):
         # Parse each resource.
diff --git a/geopy/geocoders/geolake.py b/geopy/geocoders/geolake.py
index faabbae..58678e3 100644
--- a/geopy/geocoders/geolake.py
+++ b/geopy/geocoders/geolake.py
@@ -1,4 +1,5 @@
 import collections.abc
+from functools import partial
 from urllib.parse import urlencode
 
 from geopy.geocoders.base import DEFAULT_SENTINEL, Geocoder
@@ -149,9 +150,8 @@ class Geolake(Geocoder):
         url = "?".join((self.api, urlencode(params)))
 
         logger.debug("%s.geocode: %s", self.__class__.__name__, url)
-        return self._parse_json(
-            self._call_geocoder(url, timeout=timeout), exactly_one
-        )
+        callback = partial(self._parse_json, exactly_one=exactly_one)
+        return self._call_geocoder(url, callback, timeout=timeout)
 
     def _parse_json(self, page, exactly_one):
         """Returns location, (latitude, longitude) from json feed."""
diff --git a/geopy/geocoders/geonames.py b/geopy/geocoders/geonames.py
index 66f5701..15a8a0b 100644
--- a/geopy/geocoders/geonames.py
+++ b/geopy/geocoders/geonames.py
@@ -1,3 +1,4 @@
+from functools import partial
 from urllib.parse import urlencode
 
 from geopy.exc import (
@@ -152,10 +153,8 @@ class GeoNames(Geocoder):
             params.append(('maxRows', 1))
         url = "?".join((self.api, urlencode(params)))
         logger.debug("%s.geocode: %s", self.__class__.__name__, url)
-        return self._parse_json(
-            self._call_geocoder(url, timeout=timeout),
-            exactly_one,
-        )
+        callback = partial(self._parse_json, exactly_one=exactly_one)
+        return self._call_geocoder(url, callback, timeout=timeout)
 
     def reverse(
             self,
@@ -235,10 +234,8 @@ class GeoNames(Geocoder):
             )
 
         logger.debug("%s.reverse: %s", self.__class__.__name__, url)
-        return self._parse_json(
-            self._call_geocoder(url, timeout=timeout),
-            exactly_one
-        )
+        callback = partial(self._parse_json, exactly_one=exactly_one)
+        return self._call_geocoder(url, callback, timeout=timeout)
 
     def _reverse_find_nearby_params(self, lat, lng, feature_code):
         params = {
@@ -295,9 +292,7 @@ class GeoNames(Geocoder):
         url = "?".join((self.api_timezone, urlencode(params)))
 
         logger.debug("%s.reverse_timezone: %s", self.__class__.__name__, url)
-        return self._parse_json_timezone(
-            self._call_geocoder(url, timeout=timeout)
-        )
+        return self._call_geocoder(url, self._parse_json_timezone, timeout=timeout)
 
     def _raise_for_error(self, body):
         err = body.get('status')
diff --git a/geopy/geocoders/googlev3.py b/geopy/geocoders/googlev3.py
index 9b7adc9..9583707 100644
--- a/geopy/geocoders/googlev3.py
+++ b/geopy/geocoders/googlev3.py
@@ -5,6 +5,7 @@ import hmac
 import warnings
 from calendar import timegm
 from datetime import datetime
+from functools import partial
 from urllib.parse import urlencode
 
 from geopy.exc import ConfigurationError, GeocoderQueryError, GeocoderQuotaExceeded
@@ -261,9 +262,8 @@ class GoogleV3(Geocoder):
             url = "?".join((self.api, urlencode(params)))
 
         logger.debug("%s.geocode: %s", self.__class__.__name__, url)
-        return self._parse_json(
-            self._call_geocoder(url, timeout=timeout), exactly_one
-        )
+        callback = partial(self._parse_json, exactly_one=exactly_one)
+        return self._call_geocoder(url, callback, timeout=timeout)
 
     def reverse(
             self,
@@ -314,9 +314,8 @@ class GoogleV3(Geocoder):
             url = self._get_signed_url(params)
 
         logger.debug("%s.reverse: %s", self.__class__.__name__, url)
-        return self._parse_json(
-            self._call_geocoder(url, timeout=timeout), exactly_one
-        )
+        callback = partial(self._parse_json, exactly_one=exactly_one)
+        return self._call_geocoder(url, callback, timeout=timeout)
 
     def reverse_timezone(self, query, *, at_time=None, timeout=DEFAULT_SENTINEL):
         """
@@ -357,9 +356,7 @@ class GoogleV3(Geocoder):
         url = "?".join((self.tz_api, urlencode(params)))
 
         logger.debug("%s.reverse_timezone: %s", self.__class__.__name__, url)
-        return self._parse_json_timezone(
-            self._call_geocoder(url, timeout=timeout)
-        )
+        return self._call_geocoder(url, self._parse_json_timezone, timeout=timeout)
 
     def _parse_json_timezone(self, response):
         status = response.get('status')
diff --git a/geopy/geocoders/here.py b/geopy/geocoders/here.py
index a82b757..5665eab 100644
--- a/geopy/geocoders/here.py
+++ b/geopy/geocoders/here.py
@@ -1,5 +1,6 @@
 import collections.abc
 import warnings
+from functools import partial
 from urllib.parse import urlencode
 
 from geopy.exc import (
@@ -238,10 +239,8 @@ class Here(Geocoder):
 
         url = "?".join((self.api, urlencode(params)))
         logger.debug("%s.geocode: %s", self.__class__.__name__, url)
-        return self._parse_json(
-            self._call_geocoder(url, timeout=timeout),
-            exactly_one
-        )
+        callback = partial(self._parse_json, exactly_one=exactly_one)
+        return self._call_geocoder(url, callback, timeout=timeout)
 
     def reverse(
             self,
@@ -319,10 +318,8 @@ class Here(Geocoder):
             params['app_code'] = self.app_code
         url = "%s?%s" % (self.reverse_api, urlencode(params))
         logger.debug("%s.reverse: %s", self.__class__.__name__, url)
-        return self._parse_json(
-            self._call_geocoder(url, timeout=timeout),
-            exactly_one
-        )
+        callback = partial(self._parse_json, exactly_one=exactly_one)
+        return self._call_geocoder(url, callback, timeout=timeout)
 
     def _parse_json(self, doc, exactly_one=True):
         """
diff --git a/geopy/geocoders/ignfrance.py b/geopy/geocoders/ignfrance.py
index 759661f..512b8c4 100644
--- a/geopy/geocoders/ignfrance.py
+++ b/geopy/geocoders/ignfrance.py
@@ -1,5 +1,6 @@
 import base64
 import xml.etree.ElementTree as ET
+from functools import partial
 from urllib.parse import urlencode
 
 from geopy.exc import ConfigurationError, GeocoderQueryError
@@ -222,14 +223,10 @@ class IGNFrance(Geocoder):
         url = "?".join((self.api, urlencode(params)))
 
         logger.debug("%s.geocode: %s", self.__class__.__name__, url)
-
-        raw_xml = self._request_raw_content(url, timeout)
-
-        return self._parse_xml(
-            raw_xml,
-            is_freeform=is_freeform,
-            exactly_one=exactly_one
+        callback = partial(
+            self._parse_xml, is_freeform=is_freeform, exactly_one=exactly_one
         )
+        return self._request_raw_content(url, callback, timeout=timeout)
 
     def reverse(
             self,
@@ -315,15 +312,13 @@ class IGNFrance(Geocoder):
         url = "?".join((self.api, urlencode({'xls': request_string})))
 
         logger.debug("%s.reverse: %s", self.__class__.__name__, url)
-
-        raw_xml = self._request_raw_content(url, timeout)
-
-        return self._parse_xml(
-            raw_xml,
+        callback = partial(
+            self._parse_xml,
             exactly_one=exactly_one,
             is_reverse=True,
             is_freeform='false'
         )
+        return self._request_raw_content(url, callback, timeout=timeout)
 
     def _parse_xml(self,
                    page,
@@ -450,7 +445,7 @@ class IGNFrance(Geocoder):
 
         return places
 
-    def _request_raw_content(self, url, timeout):
+    def _request_raw_content(self, url, callback, *, timeout):
         """
         Send the request to get raw content.
         """
@@ -463,15 +458,14 @@ class IGNFrance(Geocoder):
             auth_str = base64.standard_b64encode(credentials).decode()
             headers['Authorization'] = 'Basic {}'.format(auth_str.strip())
 
-        raw_xml = self._call_geocoder(
+        return self._call_geocoder(
             url,
+            callback,
             headers=headers,
             timeout=timeout,
             is_json=False,
         )
 
-        return raw_xml
-
     def _parse_place(self, place, is_freeform=None):
         """
         Get the location, lat, lng and place from a single json place.
diff --git a/geopy/geocoders/mapbox.py b/geopy/geocoders/mapbox.py
index 24f5ca2..e1c2425 100644
--- a/geopy/geocoders/mapbox.py
+++ b/geopy/geocoders/mapbox.py
@@ -1,3 +1,4 @@
+from functools import partial
 from urllib.parse import quote, urlencode
 
 from geopy.geocoders.base import DEFAULT_SENTINEL, Geocoder
@@ -150,10 +151,8 @@ class MapBox(Geocoder):
         url = "?".join((self.api % dict(query=quoted_query),
                         urlencode(params)))
         logger.debug("%s.geocode: %s", self.__class__.__name__, url)
-
-        return self._parse_json(
-            self._call_geocoder(url, timeout=timeout), exactly_one
-        )
+        callback = partial(self._parse_json, exactly_one=exactly_one)
+        return self._call_geocoder(url, callback, timeout=timeout)
 
     def reverse(
             self,
@@ -189,7 +188,5 @@ class MapBox(Geocoder):
         url = "?".join((self.api % dict(query=quoted_query),
                         urlencode(params)))
         logger.debug("%s.reverse: %s", self.__class__.__name__, url)
-
-        return self._parse_json(
-            self._call_geocoder(url, timeout=timeout), exactly_one
-        )
+        callback = partial(self._parse_json, exactly_one=exactly_one)
+        return self._call_geocoder(url, callback, timeout=timeout)
diff --git a/geopy/geocoders/mapquest.py b/geopy/geocoders/mapquest.py
index 7e9ac6e..8d000ff 100644
--- a/geopy/geocoders/mapquest.py
+++ b/geopy/geocoders/mapquest.py
@@ -1,3 +1,4 @@
+from functools import partial
 from urllib.parse import urlencode
 
 from geopy.geocoders.base import DEFAULT_SENTINEL, Geocoder
@@ -168,10 +169,8 @@ class MapQuest(Geocoder):
         url = '?'.join((self.geocode_api, urlencode(params)))
 
         logger.debug("%s.geocode: %s", self.__class__.__name__, url)
-
-        return self._parse_json(
-            self._call_geocoder(url, timeout=timeout), exactly_one
-        )
+        callback = partial(self._parse_json, exactly_one=exactly_one)
+        return self._call_geocoder(url, callback, timeout=timeout)
 
     def reverse(
             self,
@@ -208,7 +207,5 @@ class MapQuest(Geocoder):
         url = '?'.join((self.reverse_api, urlencode(params)))
 
         logger.debug("%s.reverse: %s", self.__class__.__name__, url)
-
-        return self._parse_json(
-            self._call_geocoder(url, timeout=timeout), exactly_one
-        )
+        callback = partial(self._parse_json, exactly_one=exactly_one)
+        return self._call_geocoder(url, callback, timeout=timeout)
diff --git a/geopy/geocoders/maptiler.py b/geopy/geocoders/maptiler.py
index ee877d3..a149477 100644
--- a/geopy/geocoders/maptiler.py
+++ b/geopy/geocoders/maptiler.py
@@ -1,3 +1,4 @@
+from functools import partial
 from urllib.parse import quote, urlencode
 
 from geopy.geocoders.base import DEFAULT_SENTINEL, Geocoder
@@ -147,9 +148,8 @@ class MapTiler(Geocoder):
         url = "?".join((self.api % dict(query=quoted_query),
                         urlencode(params)))
         logger.debug("%s.geocode: %s", self.__class__.__name__, url)
-        return self._parse_json(
-            self._call_geocoder(url, timeout=timeout), exactly_one
-        )
+        callback = partial(self._parse_json, exactly_one=exactly_one)
+        return self._call_geocoder(url, callback, timeout=timeout)
 
     def reverse(
             self,
@@ -194,6 +194,5 @@ class MapTiler(Geocoder):
         url = "?".join((self.api % dict(query=quoted_query),
                         urlencode(params)))
         logger.debug("%s.reverse: %s", self.__class__.__name__, url)
-        return self._parse_json(
-            self._call_geocoder(url, timeout=timeout), exactly_one
-        )
+        callback = partial(self._parse_json, exactly_one=exactly_one)
+        return self._call_geocoder(url, callback, timeout=timeout)
diff --git a/geopy/geocoders/opencage.py b/geopy/geocoders/opencage.py
index 36cba20..fd26609 100644
--- a/geopy/geocoders/opencage.py
+++ b/geopy/geocoders/opencage.py
@@ -1,3 +1,4 @@
+from functools import partial
 from urllib.parse import urlencode
 
 from geopy.exc import GeocoderQueryError, GeocoderQuotaExceeded
@@ -139,9 +140,8 @@ class OpenCage(Geocoder):
         url = "?".join((self.api, urlencode(params)))
 
         logger.debug("%s.geocode: %s", self.__class__.__name__, url)
-        return self._parse_json(
-            self._call_geocoder(url, timeout=timeout), exactly_one
-        )
+        callback = partial(self._parse_json, exactly_one=exactly_one)
+        return self._call_geocoder(url, callback, timeout=timeout)
 
     def reverse(
             self,
@@ -183,9 +183,8 @@ class OpenCage(Geocoder):
 
         url = "?".join((self.api, urlencode(params)))
         logger.debug("%s.reverse: %s", self.__class__.__name__, url)
-        return self._parse_json(
-            self._call_geocoder(url, timeout=timeout), exactly_one
-        )
+        callback = partial(self._parse_json, exactly_one=exactly_one)
+        return self._call_geocoder(url, callback, timeout=timeout)
 
     def _parse_json(self, page, exactly_one=True):
         '''Returns location, (latitude, longitude) from json feed.'''
diff --git a/geopy/geocoders/osm.py b/geopy/geocoders/osm.py
index 69b25f8..94bf2f5 100644
--- a/geopy/geocoders/osm.py
+++ b/geopy/geocoders/osm.py
@@ -1,4 +1,5 @@
 import collections.abc
+from functools import partial
 from urllib.parse import urlencode
 
 from geopy.exc import ConfigurationError, GeocoderQueryError
@@ -289,10 +290,8 @@ class Nominatim(Geocoder):
 
         url = self._construct_url(self.api, params)
         logger.debug("%s.geocode: %s", self.__class__.__name__, url)
-
-        return self._parse_json(
-            self._call_geocoder(url, timeout=timeout), exactly_one
-        )
+        callback = partial(self._parse_json, exactly_one=exactly_one)
+        return self._call_geocoder(url, callback, timeout=timeout)
 
     def reverse(
             self,
@@ -356,10 +355,8 @@ class Nominatim(Geocoder):
 
         url = self._construct_url(self.reverse_api, params)
         logger.debug("%s.reverse: %s", self.__class__.__name__, url)
-
-        return self._parse_json(
-            self._call_geocoder(url, timeout=timeout), exactly_one
-        )
+        callback = partial(self._parse_json, exactly_one=exactly_one)
+        return self._call_geocoder(url, callback, timeout=timeout)
 
     def _parse_code(self, place):
         # Parse each resource.
diff --git a/geopy/geocoders/pelias.py b/geopy/geocoders/pelias.py
index c83289c..1ace7e0 100644
--- a/geopy/geocoders/pelias.py
+++ b/geopy/geocoders/pelias.py
@@ -1,3 +1,4 @@
+from functools import partial
 from urllib.parse import urlencode
 
 from geopy.geocoders.base import DEFAULT_SENTINEL, Geocoder
@@ -143,9 +144,8 @@ class Pelias(Geocoder):
 
         url = "?".join((self.geocode_api, urlencode(params)))
         logger.debug("%s.geocode_api: %s", self.__class__.__name__, url)
-        return self._parse_json(
-            self._call_geocoder(url, timeout=timeout), exactly_one
-        )
+        callback = partial(self._parse_json, exactly_one=exactly_one)
+        return self._call_geocoder(url, callback, timeout=timeout)
 
     def reverse(
             self,
@@ -199,9 +199,8 @@ class Pelias(Geocoder):
 
         url = "?".join((self.reverse_api, urlencode(params)))
         logger.debug("%s.reverse: %s", self.__class__.__name__, url)
-        return self._parse_json(
-            self._call_geocoder(url, timeout=timeout), exactly_one
-        )
+        callback = partial(self._parse_json, exactly_one=exactly_one)
+        return self._call_geocoder(url, callback, timeout=timeout)
 
     def _parse_code(self, feature):
         # Parse each resource.
diff --git a/geopy/geocoders/photon.py b/geopy/geocoders/photon.py
index ab43f81..33431e7 100644
--- a/geopy/geocoders/photon.py
+++ b/geopy/geocoders/photon.py
@@ -1,4 +1,5 @@
 import collections.abc
+from functools import partial
 from urllib.parse import urlencode
 
 from geopy.geocoders.base import DEFAULT_SENTINEL, Geocoder
@@ -141,10 +142,8 @@ class Photon(Geocoder):
                 params['osm_tag'] = list(osm_tag)
         url = "?".join((self.api, urlencode(params, doseq=True)))
         logger.debug("%s.geocode: %s", self.__class__.__name__, url)
-        return self._parse_json(
-            self._call_geocoder(url, timeout=timeout),
-            exactly_one
-        )
+        callback = partial(self._parse_json, exactly_one=exactly_one)
+        return self._call_geocoder(url, callback, timeout=timeout)
 
     def reverse(
             self,
@@ -195,9 +194,8 @@ class Photon(Geocoder):
             params['lang'] = language
         url = "?".join((self.reverse_api, urlencode(params)))
         logger.debug("%s.reverse: %s", self.__class__.__name__, url)
-        return self._parse_json(
-            self._call_geocoder(url, timeout=timeout), exactly_one
-        )
+        callback = partial(self._parse_json, exactly_one=exactly_one)
+        return self._call_geocoder(url, callback, timeout=timeout)
 
     def _parse_json(self, resources, exactly_one=True):
         """
diff --git a/geopy/geocoders/smartystreets.py b/geopy/geocoders/smartystreets.py
index ff4549d..ba3c562 100644
--- a/geopy/geocoders/smartystreets.py
+++ b/geopy/geocoders/smartystreets.py
@@ -1,3 +1,4 @@
+from functools import partial
 from urllib.parse import urlencode
 
 from geopy.adapters import AdapterHTTPError
@@ -108,8 +109,8 @@ class LiveAddress(Geocoder):
         url = '{url}?{query}'.format(url=self.api, query=urlencode(query))
 
         logger.debug("%s.geocode: %s", self.__class__.__name__, url)
-        return self._parse_json(self._call_geocoder(url, timeout=timeout),
-                                exactly_one)
+        callback = partial(self._parse_json, exactly_one=exactly_one)
+        return self._call_geocoder(url, callback, timeout=timeout)
 
     def _geocoder_exception_handler(self, error):
         search = "no active subscriptions found"
diff --git a/geopy/geocoders/tomtom.py b/geopy/geocoders/tomtom.py
index c1b1521..4d98f0e 100644
--- a/geopy/geocoders/tomtom.py
+++ b/geopy/geocoders/tomtom.py
@@ -1,3 +1,4 @@
+from functools import partial
 from urllib.parse import quote, urlencode
 
 from geopy.adapters import AdapterHTTPError
@@ -124,10 +125,8 @@ class TomTom(Geocoder):
         url = "?".join((self.api % dict(query=quoted_query),
                         urlencode(params)))
         logger.debug("%s.geocode: %s", self.__class__.__name__, url)
-
-        return self._parse_json(
-            self._call_geocoder(url, timeout=timeout), exactly_one
-        )
+        callback = partial(self._parse_json, exactly_one=exactly_one)
+        return self._call_geocoder(url, callback, timeout=timeout)
 
     def reverse(
             self,
@@ -172,10 +171,8 @@ class TomTom(Geocoder):
         url = "?".join((self.api_reverse % dict(position=quoted_position),
                         urlencode(params)))
         logger.debug("%s.reverse: %s", self.__class__.__name__, url)
-
-        return self._parse_reverse_json(
-            self._call_geocoder(url, timeout=timeout), exactly_one
-        )
+        callback = partial(self._parse_reverse_json, exactly_one=exactly_one)
+        return self._call_geocoder(url, callback, timeout=timeout)
 
     def _boolean_value(self, bool_value):
         return 'true' if bool_value else 'false'
diff --git a/geopy/geocoders/what3words.py b/geopy/geocoders/what3words.py
index 542ed11..8c2c736 100644
--- a/geopy/geocoders/what3words.py
+++ b/geopy/geocoders/what3words.py
@@ -1,4 +1,5 @@
 import re
+from functools import partial
 from urllib.parse import urlencode
 
 from geopy import exc
@@ -125,10 +126,8 @@ class What3Words(Geocoder):
 
         url = "?".join((self.geocode_api, urlencode(params)))
         logger.debug("%s.geocode: %s", self.__class__.__name__, url)
-        return self._parse_json(
-            self._call_geocoder(url, timeout=timeout),
-            exactly_one=exactly_one
-        )
+        callback = partial(self._parse_json, exactly_one=exactly_one)
+        return self._call_geocoder(url, callback, timeout=timeout)
 
     def _parse_json(self, resources, exactly_one=True):
         """
@@ -214,10 +213,8 @@ class What3Words(Geocoder):
         url = "?".join((self.reverse_api, urlencode(params)))
 
         logger.debug("%s.reverse: %s", self.__class__.__name__, url)
-        return self._parse_reverse_json(
-            self._call_geocoder(url, timeout=timeout),
-            exactly_one=exactly_one
-        )
+        callback = partial(self._parse_reverse_json, exactly_one=exactly_one)
+        return self._call_geocoder(url, callback, timeout=timeout)
 
     def _parse_reverse_json(self, resources, exactly_one=True):
         """
diff --git a/geopy/geocoders/yandex.py b/geopy/geocoders/yandex.py
index 3b560ee..fe7bf2a 100644
--- a/geopy/geocoders/yandex.py
+++ b/geopy/geocoders/yandex.py
@@ -1,3 +1,4 @@
+from functools import partial
 from urllib.parse import urlencode
 
 from geopy.exc import GeocoderParseError, GeocoderServiceError
@@ -115,10 +116,8 @@ class Yandex(Geocoder):
             params['results'] = 1
         url = "?".join((self.api, urlencode(params)))
         logger.debug("%s.geocode: %s", self.__class__.__name__, url)
-        return self._parse_json(
-            self._call_geocoder(url, timeout=timeout),
-            exactly_one,
-        )
+        callback = partial(self._parse_json, exactly_one=exactly_one)
+        return self._call_geocoder(url, callback, timeout=timeout)
 
     def reverse(
             self,
@@ -177,10 +176,8 @@ class Yandex(Geocoder):
             params['kind'] = kind
         url = "?".join((self.api, urlencode(params)))
         logger.debug("%s.reverse: %s", self.__class__.__name__, url)
-        return self._parse_json(
-            self._call_geocoder(url, timeout=timeout),
-            exactly_one
-        )
+        callback = partial(self._parse_json, exactly_one=exactly_one)
+        return self._call_geocoder(url, callback, timeout=timeout)
 
     def _parse_json(self, doc, exactly_one):
         """


Test patch:
diff --git a/test/geocoders/base.py b/test/geocoders/base.py
index 7af7a26..55f713d 100644
--- a/test/geocoders/base.py
+++ b/test/geocoders/base.py
@@ -6,9 +6,10 @@ import pytest
 
 import geopy.geocoders
 import geopy.geocoders.base
+from geopy.adapters import URLLibAdapter
 from geopy.exc import GeocoderNotFound, GeocoderQueryError
 from geopy.geocoders import GoogleV3, get_geocoder_for_service
-from geopy.geocoders.base import Geocoder
+from geopy.geocoders.base import Geocoder, _synchronized
 from geopy.point import Point
 
 
@@ -103,15 +104,15 @@ class GeocoderTestCase(unittest.TestCase):
         with ExitStack() as stack:
             mock_get_json = stack.enter_context(patch.object(g.adapter, 'get_json'))
 
-            g._call_geocoder(url)
+            g._call_geocoder(url, lambda res: res)
             args, kwargs = mock_get_json.call_args
             assert kwargs['timeout'] == 12
 
-            g._call_geocoder(url, timeout=7)
+            g._call_geocoder(url, lambda res: res, timeout=7)
             args, kwargs = mock_get_json.call_args
             assert kwargs['timeout'] == 7
 
-            g._call_geocoder(url, timeout=None)
+            g._call_geocoder(url, lambda res: res, timeout=None)
             args, kwargs = mock_get_json.call_args
             assert kwargs['timeout'] is None
 
@@ -207,3 +208,44 @@ class GeocoderFormatBoundingBoxTestCase(unittest.TestCase):
         bbox = self.method([Point(50, 160), Point(30, 170)],
                            " %(lon2)s|%(lat2)s -- %(lat1)s|%(lon1)s ")
         assert bbox == " 170.0|50.0 -- 30.0|160.0 "
+
+
+def test_synchronize_decorator_sync_simple():
+    geocoder = Geocoder(adapter_factory=URLLibAdapter)
+    calls = []
+
+    @_synchronized
+    def f(self, one, *, two):
+        calls.append((one, two))
+        return 42
+
+    assert 42 == f(geocoder, 1, two=2)
+    assert calls == [(1, 2)]
+
+
+def test_synchronize_decorator_sync_exception():
+    geocoder = Geocoder(adapter_factory=URLLibAdapter)
+
+    @_synchronized
+    def f(self, one, *, two):
+        raise RuntimeError("test")
+
+    with pytest.raises(RuntimeError):
+        f(geocoder, 1, two=2)
+
+
+def test_synchronize_decorator_sync_reentrance():
+    calls = []
+
+    class DummyGeocoder(Geocoder):
+        @_synchronized
+        def f(self, i=0):
+            calls.append(i)
+            if len(calls) < 5:
+                return self.f(i + 1)
+            return 42
+
+    geocoder = DummyGeocoder(adapter_factory=URLLibAdapter)
+
+    assert 42 == geocoder.f()
+    assert calls == list(range(5))
diff --git a/test/geocoders/geocodefarm.py b/test/geocoders/geocodefarm.py
index 0c18004..79a3ced 100644
--- a/test/geocoders/geocodefarm.py
+++ b/test/geocoders/geocodefarm.py
@@ -64,19 +64,19 @@ class GeocodeFarmTestCase(GeocoderTestBase):
 
     def test_quota_exceeded(self):
 
-        def mock_call_geocoder(*args, **kwargs):
-            return {
+        def mock_call_geocoder(url, callback, **kwargs):
+            return callback({
                 "geocoding_results": {
                     "STATUS": {
                         "access": "OVER_QUERY_LIMIT",
                         "status": "FAILED, ACCESS_DENIED"
                     }
                 }
-            }
+            })
 
-        with patch.object(self.geocoder, '_call_geocoder', mock_call_geocoder), \
-                pytest.raises(exc.GeocoderQuotaExceeded):
-            self.geocoder.geocode('435 north michigan ave, chicago il 60611')
+        with patch.object(self.geocoder, '_call_geocoder', mock_call_geocoder):
+            with pytest.raises(exc.GeocoderQuotaExceeded):
+                self.geocoder.geocode('435 north michigan ave, chicago il 60611')
 
     def test_no_results(self):
         self.geocode_run(
@@ -87,16 +87,16 @@ class GeocodeFarmTestCase(GeocoderTestBase):
 
     def test_unhandled_api_error(self):
 
-        def mock_call_geocoder(*args, **kwargs):
-            return {
+        def mock_call_geocoder(url, callback, **kwargs):
+            return callback({
                 "geocoding_results": {
                     "STATUS": {
                         "access": "BILL_PAST_DUE",
                         "status": "FAILED, ACCESS_DENIED"
                     }
                 }
-            }
+            })
 
-        with patch.object(self.geocoder, '_call_geocoder', mock_call_geocoder), \
-                pytest.raises(exc.GeocoderServiceError):
-            self.geocoder.geocode('435 north michigan ave, chicago il 60611')
+        with patch.object(self.geocoder, '_call_geocoder', mock_call_geocoder):
+            with pytest.raises(exc.GeocoderServiceError):
+                self.geocoder.geocode('435 north michigan ave, chicago il 60611')
diff --git a/test/test_adapters.py b/test/test_adapters.py
index 826ccda..f4f1f4a 100644
--- a/test/test_adapters.py
+++ b/test/test_adapters.py
@@ -25,8 +25,9 @@ WITH_SYSTEM_PROXIES = bool(getproxies())
 
 class DummyGeocoder(Geocoder):
     def geocode(self, location, *, is_json=False):
-        geo_html = self._call_geocoder(location, is_json=is_json)
-        return geo_html if geo_html else None
+        return self._call_geocoder(
+            location, lambda res: res if res else None, is_json=is_json
+        )
 
 
 @pytest.mark.usefixtures("skip_if_internet_access_is_not_allowed")