Skip to content

Commit 7584855

Browse files
authored
allow setting ssl context in case of SSL certificate errors (#52)
1 parent 1e0117c commit 7584855

8 files changed

Lines changed: 199 additions & 42 deletions

File tree

Changes.txt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1-
unreleased
1+
v2.3.1 Wed Nov 15 2023
2+
New error 'SSLError' which is more explicit in case of SSL certificate chain issues
3+
Allow setting a domain name (only used in test suite)
4+
Allow setting sslcontext, for example to ignore SSL certificat errors (for debugging only)
25
Batch example: Guess if input is coordinate pair, if so then do reverse geocoding
36
Batch example: Give example of input file format
7+
Batch example: Ship CA root certificates instead of relying on those of the operating system
48

59
v2.3.0 Tue 04 Jul 2023
610
Batch example: Raise exception when API key fails (quota, missing API key)

examples/batch.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,21 @@
3232
import sys
3333
import csv
3434
import re
35+
import ssl
3536
import asyncio
3637
import traceback
38+
import aiohttp
3739
import backoff
40+
import certifi
3841
from tqdm import tqdm
39-
from opencage.geocoder import OpenCageGeocode, AioHttpError
42+
from opencage.geocoder import OpenCageGeocode, SSLError
4043

44+
# Use certificates from the certifi package instead of those of the operating system
45+
# https://pypi.org/project/certifi/
46+
# https://docs.aiohttp.org/en/stable/client_advanced.html#ssl-control-for-tcp-sockets
47+
sslcontext = ssl.create_default_context(cafile=certifi.where())
48+
# Alternatively set sslcontext=False to ignore certificate validation (not advised)
49+
# or sslcontext=None to use those of the operating system
4150

4251

4352

@@ -49,11 +58,11 @@
4958
FORWARD_OR_REVERSE = 'guess' # 'forward' (address -> coordinates) or 'reverse' (coordinates -> address)
5059
# With 'guess' the script checks if the address is two numbers and then
5160
# assumes reverse
52-
61+
API_DOMAIN = 'api.opencagedata.com'
5362
MAX_ITEMS = 100 # How many lines to read from the input file. Set to 0 for unlimited
5463
NUM_WORKERS = 3 # For 10 requests per second try 2-5
5564
REQUEST_TIMEOUT_SECONDS = 5 # For individual HTTP requests. Default is 1
56-
RETRY_MAX_TRIES = 10 # How often to retry if a HTTP request times out
65+
RETRY_MAX_TRIES = 10 # How often to retry if a HTTP request times out
5766
RETRY_MAX_TIME = 60 # Limit in seconds for retries
5867
SHOW_PROGRESS = True # Show progress bar
5968

@@ -133,7 +142,7 @@ def backoff_hdlr(details):
133142
max_tries=RETRY_MAX_TRIES,
134143
on_backoff=backoff_hdlr)
135144
async def geocode_one_address(address, address_id):
136-
async with OpenCageGeocode(API_KEY) as geocoder:
145+
async with OpenCageGeocode(API_KEY, domain=API_DOMAIN, sslcontext=sslcontext) as geocoder:
137146
global FORWARD_OR_REVERSE
138147
try:
139148
if FORWARD_OR_REVERSE == 'reverse' or \
@@ -150,6 +159,9 @@ async def geocode_one_address(address, address_id):
150159
# countrycode, language, etc
151160
# see the full list: https://opencagedata.com/api#forward-opt
152161
geocoding_results = await geocoder.geocode_async(address, no_annotations=1)
162+
except SSLError as exc:
163+
sys.stderr.write(str(exc))
164+
153165
except Exception as exc:
154166
traceback.print_exception(exc, file=sys.stderr)
155167

opencage/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22

33
__author__ = "OpenCage GmbH"
44
__email__ = 'support@opencagedata.com'
5-
__version__ = '2.3.0'
5+
__version__ = '2.3.1'

opencage/geocoder.py

Lines changed: 58 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
except ImportError:
1515
AIOHTTP_AVAILABLE = False
1616

17+
DEFAULT_DOMAIN = 'api.opencagedata.com'
18+
1719
def backoff_max_time():
1820
return int(os.environ.get('BACKOFF_MAX_TIME', '120'))
1921

@@ -102,6 +104,21 @@ class AioHttpError(OpenCageGeocodeError):
102104
"""
103105

104106

107+
class SSLError(OpenCageGeocodeError):
108+
109+
"""
110+
Exception raised when SSL connection to OpenCage server fails.
111+
"""
112+
113+
def __unicode__(self):
114+
"""Convert exception to a string."""
115+
return ("SSL Certificate error connecting to OpenCage API. This is usually due to "
116+
"outdated CA root certificates of the operating system. "
117+
)
118+
119+
__str__ = __unicode__
120+
121+
105122
class OpenCageGeocode:
106123

107124
"""
@@ -121,15 +138,18 @@ class OpenCageGeocode:
121138
122139
"""
123140

124-
url = 'https://api.opencagedata.com/geocode/v1/json'
125-
key = ''
126141
session = None
127142

128-
def __init__(self, key, protocol='https'):
143+
def __init__(self, key, protocol='https', domain=DEFAULT_DOMAIN, sslcontext=None):
129144
"""Constructor."""
130145
self.key = key
131-
if protocol and protocol == 'http':
132-
self.url = self.url.replace('https://', 'http://')
146+
147+
if protocol and protocol not in ('http', 'https'):
148+
protocol = 'https'
149+
self.url = protocol + '://' + domain + '/geocode/v1/json'
150+
151+
# https://docs.aiohttp.org/en/stable/client_advanced.html#ssl-control-for-tcp-sockets
152+
self.sslcontext = sslcontext
133153

134154
def __enter__(self):
135155
self.session = requests.Session()
@@ -167,7 +187,7 @@ def geocode(self, query, **kwargs):
167187
"""
168188

169189
if self.session and isinstance(self.session, aiohttp.client.ClientSession):
170-
raise AioHttpError("Cannot use `geocode` in an async context, use `gecode_async`.")
190+
raise AioHttpError("Cannot use `geocode` in an async context, use `geocode_async`.")
171191

172192
request = self._parse_request(query, kwargs)
173193
response = self._opencage_request(request)
@@ -271,34 +291,38 @@ def _opencage_request(self, params):
271291
return response_json
272292

273293
async def _opencage_async_request(self, params):
274-
async with self.session.get(self.url, params=params) as response:
275-
try:
276-
response_json = await response.json()
277-
except ValueError as excinfo:
278-
raise UnknownError("Non-JSON result from server") from excinfo
279-
280-
if response.status == 401:
281-
raise NotAuthorizedError()
282-
283-
if response.status == 403:
284-
raise ForbiddenError()
285-
286-
if response.status in (402, 429):
287-
# Rate limit exceeded
288-
print(response_json)
289-
reset_time = datetime.utcfromtimestamp(response_json['rate']['reset'])
290-
raise RateLimitExceededError(
291-
reset_to=int(response_json['rate']['limit']),
292-
reset_time=reset_time
293-
)
294-
295-
if response.status == 500:
296-
raise UnknownError("500 status code from API")
297-
298-
if 'results' not in response_json:
299-
raise UnknownError("JSON from API doesn't have a 'results' key")
300-
301-
return response_json
294+
try:
295+
async with self.session.get(self.url, params=params, ssl=self.sslcontext) as response:
296+
try:
297+
response_json = await response.json()
298+
except ValueError as excinfo:
299+
raise UnknownError("Non-JSON result from server") from excinfo
300+
301+
if response.status == 401:
302+
raise NotAuthorizedError()
303+
304+
if response.status == 403:
305+
raise ForbiddenError()
306+
307+
if response.status in (402, 429):
308+
# Rate limit exceeded
309+
reset_time = datetime.utcfromtimestamp(response_json['rate']['reset'])
310+
raise RateLimitExceededError(
311+
reset_to=int(response_json['rate']['limit']),
312+
reset_time=reset_time
313+
)
314+
315+
if response.status == 500:
316+
raise UnknownError("500 status code from API")
317+
318+
if 'results' not in response_json:
319+
raise UnknownError("JSON from API doesn't have a 'results' key")
320+
321+
return response_json
322+
except aiohttp.ClientSSLError as exp:
323+
raise SSLError() from exp
324+
except aiohttp.client_exceptions.ClientConnectorCertificateError as exp:
325+
raise SSLError() from exp
302326

303327
def _parse_request(self, query, params):
304328
if not isinstance(query, str):

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828

2929
setup(
3030
name="opencage",
31-
version="2.3.0",
31+
version="2.3.1",
3232
description="Wrapper module for the OpenCage Geocoder API",
3333
long_description=LONG_DESCRIPTION,
3434
long_description_content_type='text/markdown',

test/fixtures/badssl-com-chain.pem

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
-----BEGIN CERTIFICATE-----
2+
MIIE8DCCA9igAwIBAgISA4mqZntfCH8MYyIVqUF1XZgpMA0GCSqGSIb3DQEBCwUA
3+
MDIxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQswCQYDVQQD
4+
EwJSMzAeFw0yMzEwMTkxNTUwMjlaFw0yNDAxMTcxNTUwMjhaMBcxFTATBgNVBAMM
5+
DCouYmFkc3NsLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAONj
6+
dsqxZsR+pDzWX6GLCy6ImoAT60LNYvs9U6BIQ+fatIWbMELAFD6jY+IP25hrVEr1
7+
bgwRWmAAOnUc2qKXdtx6KXXO3cAJoCSHFNBDEZqzg/+exj+3emQH8dVZiYAS2Rpd
8+
nL9uKc3xgDDb74p1m7J4JdMewHmebRUmMt0MbA0f8sxvhbv9wIXkgAZd6dKYPGzJ
9+
KJlCoQifPiJ66JwYk8WVGEJH9m8LNDse388MscfsuwvAAh9tt2Fq6rmV9s21P6qf
10+
JgjePl65e8fVjsEWBAvC/aMYvTUs7Gdqej0qByjESpt1LZClNomJDvIgqA9+5KsU
11+
yCXigT6OiPjtZhdhgw0CAwEAAaOCAhkwggIVMA4GA1UdDwEB/wQEAwIFoDAdBgNV
12+
HSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwDAYDVR0TAQH/BAIwADAdBgNVHQ4E
13+
FgQUqryJ2HM8bXniU+mPXLakkgUQobMwHwYDVR0jBBgwFoAUFC6zF7dYVsuuUAlA
14+
5h+vnYsUwsYwVQYIKwYBBQUHAQEESTBHMCEGCCsGAQUFBzABhhVodHRwOi8vcjMu
15+
by5sZW5jci5vcmcwIgYIKwYBBQUHMAKGFmh0dHA6Ly9yMy5pLmxlbmNyLm9yZy8w
16+
IwYDVR0RBBwwGoIMKi5iYWRzc2wuY29tggpiYWRzc2wuY29tMBMGA1UdIAQMMAow
17+
CAYGZ4EMAQIBMIIBAwYKKwYBBAHWeQIEAgSB9ASB8QDvAHUA2ra/az+1tiKfm8K7
18+
XGvocJFxbLtRhIU0vaQ9MEjX+6sAAAGLSNh8nwAABAMARjBEAiBkJnQowOqs+tDj
19+
7qXXu0PlDCvgvtEemuw1OvInlaHSrAIgcCZV5dJmGVrS1voinEpAzScJejhGB0vb
20+
G8dfKhJZD+wAdgA7U3d1Pi25gE6LMFsG/kA7Z9hPw/THvQANLXJv4frUFwAAAYtI
21+
2HyZAAAEAwBHMEUCIQCJ+gamX0P/hGiIuu70hn8d0svHSOAMJs3D+eOjMVqsywIg
22+
JXR/lAknUTRU+SyfySDoQ22bDSXfYWZGHLFgAkiRo48wDQYJKoZIhvcNAQELBQAD
23+
ggEBAGE3PDg7p2N8aZyAyO0pGVb/ob9opu12g+diNIdRSjsKIE+TO3uClM2OxT0t
24+
5GBz6Owbe010MQtqBKmX4Zm2LSLUm1kVhPh2ohWmA4hTyN3RG5W0IJ3red6VjrJY
25+
URhZQoXQb0gonxMs+zC+4GQ7+yqzWA1UkrWrURjjJCuljyoWF9sE7qEweomSQWnV
26+
v6bIF599/di1R2l5vcRq1DsQDgKaFY4IpKnvh3RhgO19YxlSS9ERRGBem3Aml9tb
27+
Yac12RmyuxsEAr0v75YeL3pAuq/1Rd5OeKfkm+K06Px3LxwcF92RljXkH6T2U8VM
28+
PEFKedHjYjAag3DUMqSuuGI+ONU=
29+
-----END CERTIFICATE-----
30+
-----BEGIN CERTIFICATE-----
31+
MIIFFjCCAv6gAwIBAgIRAJErCErPDBinU/bWLiWnX1owDQYJKoZIhvcNAQELBQAw
32+
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
33+
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMjAwOTA0MDAwMDAw
34+
WhcNMjUwOTE1MTYwMDAwWjAyMQswCQYDVQQGEwJVUzEWMBQGA1UEChMNTGV0J3Mg
35+
RW5jcnlwdDELMAkGA1UEAxMCUjMwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK
36+
AoIBAQC7AhUozPaglNMPEuyNVZLD+ILxmaZ6QoinXSaqtSu5xUyxr45r+XXIo9cP
37+
R5QUVTVXjJ6oojkZ9YI8QqlObvU7wy7bjcCwXPNZOOftz2nwWgsbvsCUJCWH+jdx
38+
sxPnHKzhm+/b5DtFUkWWqcFTzjTIUu61ru2P3mBw4qVUq7ZtDpelQDRrK9O8Zutm
39+
NHz6a4uPVymZ+DAXXbpyb/uBxa3Shlg9F8fnCbvxK/eG3MHacV3URuPMrSXBiLxg
40+
Z3Vms/EY96Jc5lP/Ooi2R6X/ExjqmAl3P51T+c8B5fWmcBcUr2Ok/5mzk53cU6cG
41+
/kiFHaFpriV1uxPMUgP17VGhi9sVAgMBAAGjggEIMIIBBDAOBgNVHQ8BAf8EBAMC
42+
AYYwHQYDVR0lBBYwFAYIKwYBBQUHAwIGCCsGAQUFBwMBMBIGA1UdEwEB/wQIMAYB
43+
Af8CAQAwHQYDVR0OBBYEFBQusxe3WFbLrlAJQOYfr52LFMLGMB8GA1UdIwQYMBaA
44+
FHm0WeZ7tuXkAXOACIjIGlj26ZtuMDIGCCsGAQUFBwEBBCYwJDAiBggrBgEFBQcw
45+
AoYWaHR0cDovL3gxLmkubGVuY3Iub3JnLzAnBgNVHR8EIDAeMBygGqAYhhZodHRw
46+
Oi8veDEuYy5sZW5jci5vcmcvMCIGA1UdIAQbMBkwCAYGZ4EMAQIBMA0GCysGAQQB
47+
gt8TAQEBMA0GCSqGSIb3DQEBCwUAA4ICAQCFyk5HPqP3hUSFvNVneLKYY611TR6W
48+
PTNlclQtgaDqw+34IL9fzLdwALduO/ZelN7kIJ+m74uyA+eitRY8kc607TkC53wl
49+
ikfmZW4/RvTZ8M6UK+5UzhK8jCdLuMGYL6KvzXGRSgi3yLgjewQtCPkIVz6D2QQz
50+
CkcheAmCJ8MqyJu5zlzyZMjAvnnAT45tRAxekrsu94sQ4egdRCnbWSDtY7kh+BIm
51+
lJNXoB1lBMEKIq4QDUOXoRgffuDghje1WrG9ML+Hbisq/yFOGwXD9RiX8F6sw6W4
52+
avAuvDszue5L3sz85K+EC4Y/wFVDNvZo4TYXao6Z0f+lQKc0t8DQYzk1OXVu8rp2
53+
yJMC6alLbBfODALZvYH7n7do1AZls4I9d1P4jnkDrQoxB3UqQ9hVl3LEKQ73xF1O
54+
yK5GhDDX8oVfGKF5u+decIsH4YaTw7mP3GFxJSqv3+0lUFJoi5Lc5da149p90Ids
55+
hCExroL1+7mryIkXPeFM5TgO9r0rvZaBFOvV2z0gp35Z0+L4WPlbuEjN/lxPFin+
56+
HlUjr8gRsI3qfJOQFy/9rKIJR0Y/8Omwt/8oTWgy1mdeHmmjk7j1nYsvC9JSQ6Zv
57+
MldlTTKB3zhThV1+XWYp6rjd5JW1zbVWEkLNxE7GJThEUG3szgBVGP7pSWTUTsqX
58+
nLRbwHOoq7hHwg==
59+
-----END CERTIFICATE-----
60+
-----BEGIN CERTIFICATE-----
61+
MIIFYDCCBEigAwIBAgIQQAF3ITfU6UK47naqPGQKtzANBgkqhkiG9w0BAQsFADA/
62+
MSQwIgYDVQQKExtEaWdpdGFsIFNpZ25hdHVyZSBUcnVzdCBDby4xFzAVBgNVBAMT
63+
DkRTVCBSb290IENBIFgzMB4XDTIxMDEyMDE5MTQwM1oXDTI0MDkzMDE4MTQwM1ow
64+
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
65+
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwggIiMA0GCSqGSIb3DQEB
66+
AQUAA4ICDwAwggIKAoICAQCt6CRz9BQ385ueK1coHIe+3LffOJCMbjzmV6B493XC
67+
ov71am72AE8o295ohmxEk7axY/0UEmu/H9LqMZshftEzPLpI9d1537O4/xLxIZpL
68+
wYqGcWlKZmZsj348cL+tKSIG8+TA5oCu4kuPt5l+lAOf00eXfJlII1PoOK5PCm+D
69+
LtFJV4yAdLbaL9A4jXsDcCEbdfIwPPqPrt3aY6vrFk/CjhFLfs8L6P+1dy70sntK
70+
4EwSJQxwjQMpoOFTJOwT2e4ZvxCzSow/iaNhUd6shweU9GNx7C7ib1uYgeGJXDR5
71+
bHbvO5BieebbpJovJsXQEOEO3tkQjhb7t/eo98flAgeYjzYIlefiN5YNNnWe+w5y
72+
sR2bvAP5SQXYgd0FtCrWQemsAXaVCg/Y39W9Eh81LygXbNKYwagJZHduRze6zqxZ
73+
Xmidf3LWicUGQSk+WT7dJvUkyRGnWqNMQB9GoZm1pzpRboY7nn1ypxIFeFntPlF4
74+
FQsDj43QLwWyPntKHEtzBRL8xurgUBN8Q5N0s8p0544fAQjQMNRbcTa0B7rBMDBc
75+
SLeCO5imfWCKoqMpgsy6vYMEG6KDA0Gh1gXxG8K28Kh8hjtGqEgqiNx2mna/H2ql
76+
PRmP6zjzZN7IKw0KKP/32+IVQtQi0Cdd4Xn+GOdwiK1O5tmLOsbdJ1Fu/7xk9TND
77+
TwIDAQABo4IBRjCCAUIwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYw
78+
SwYIKwYBBQUHAQEEPzA9MDsGCCsGAQUFBzAChi9odHRwOi8vYXBwcy5pZGVudHJ1
79+
c3QuY29tL3Jvb3RzL2RzdHJvb3RjYXgzLnA3YzAfBgNVHSMEGDAWgBTEp7Gkeyxx
80+
+tvhS5B1/8QVYIWJEDBUBgNVHSAETTBLMAgGBmeBDAECATA/BgsrBgEEAYLfEwEB
81+
ATAwMC4GCCsGAQUFBwIBFiJodHRwOi8vY3BzLnJvb3QteDEubGV0c2VuY3J5cHQu
82+
b3JnMDwGA1UdHwQ1MDMwMaAvoC2GK2h0dHA6Ly9jcmwuaWRlbnRydXN0LmNvbS9E
83+
U1RST09UQ0FYM0NSTC5jcmwwHQYDVR0OBBYEFHm0WeZ7tuXkAXOACIjIGlj26Ztu
84+
MA0GCSqGSIb3DQEBCwUAA4IBAQAKcwBslm7/DlLQrt2M51oGrS+o44+/yQoDFVDC
85+
5WxCu2+b9LRPwkSICHXM6webFGJueN7sJ7o5XPWioW5WlHAQU7G75K/QosMrAdSW
86+
9MUgNTP52GE24HGNtLi1qoJFlcDyqSMo59ahy2cI2qBDLKobkx/J3vWraV0T9VuG
87+
WCLKTVXkcGdtwlfFRjlBz4pYg1htmf5X6DYO8A4jqv2Il9DjXA6USbW1FzXSLr9O
88+
he8Y4IWS6wY7bCkjCWDcRQJMEhg76fsO3txE+FiYruq9RUWhiF1myv4Q6W+CyBFC
89+
Dfvp7OOGAN6dEOM4+qR9sdjoSYKEBpsr6GtPAQw4dy753ec5
90+
-----END CERTIFICATE-----

test/test_async.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,4 @@ async def test_using_non_async_method():
3939
with pytest.raises(AioHttpError) as excinfo:
4040
await geocoder.geocode("Atlantis")
4141

42-
assert str(excinfo.value) == 'Cannot use `geocode` in an async context, use `gecode_async`.'
42+
assert str(excinfo.value) == 'Cannot use `geocode` in an async context, use `geocode_async`.'

test/test_error_ssl.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# encoding: utf-8
2+
3+
import ssl
4+
import pytest
5+
from opencage.geocoder import OpenCageGeocode, SSLError
6+
7+
# NOTE: Testing keys https://opencagedata.com/api#testingkeys
8+
9+
# Connect to a host that has an invalid certificate
10+
@pytest.mark.asyncio
11+
async def test_sslerror():
12+
bad_domain = 'wrong.host.badssl.com'
13+
async with OpenCageGeocode('6d0e711d72d74daeb2b0bfd2a5cdfdba', domain=bad_domain) as geocoder:
14+
with pytest.raises(SSLError) as excinfo:
15+
await geocoder.geocode_async("something")
16+
assert str(excinfo.value).startswith('SSL Certificate error')
17+
18+
# Connect to OpenCage API domain but use certificate of another domain
19+
# This tests that sslcontext can be set.
20+
@pytest.mark.asyncio
21+
async def test_sslerror_wrong_certificate():
22+
sslcontext = ssl.create_default_context(cafile='test/fixtures/badssl-com-chain.pem')
23+
24+
async with OpenCageGeocode('6d0e711d72d74daeb2b0bfd2a5cdfdba', sslcontext=sslcontext) as geocoder:
25+
with pytest.raises(SSLError) as excinfo:
26+
await geocoder.geocode_async("something")
27+
assert str(excinfo.value).startswith('SSL Certificate error')

0 commit comments

Comments
 (0)