Skip to content

Commit de03015

Browse files
stankudrowStanley Kudrowpatrick-zippenfenig
authored
fix: request session closed prematurely (#95)
* remove context managers * do not use async self._session object in the AsyncClient class * fix: session handling * linting * fix: async sessions * linting * remove unused import --------- Co-authored-by: Stanley Kudrow <stankudrow@reply.no> Co-authored-by: patrick-zippenfenig <patrick@zippenfenig.de>
1 parent 1547edd commit de03015

File tree

4 files changed

+248
-323
lines changed

4 files changed

+248
-323
lines changed

openmeteo_requests/Client.py

Lines changed: 25 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44

55
from collections.abc import MutableMapping
66
from enum import Enum
7-
from functools import partial
87
from typing import Any
98

109
import niquests
@@ -45,8 +44,8 @@ class Client:
4544
"""Open-Meteo API Client"""
4645

4746
def __init__(self, session: niquests.Session | None = None) -> None:
47+
self._close_session = session is None
4848
self._session = session or niquests.Session()
49-
self._response_cls = WeatherApiResponse
5049

5150
def _request(
5251
self,
@@ -59,19 +58,18 @@ def _request(
5958
) -> list[WeatherApiResponse]:
6059
params["format"] = _FLAT_BUFFERS_FORMAT
6160

62-
with self._session as sess:
63-
method = method.upper()
64-
if method == HTTPVerb.GET:
65-
response = sess.get(url, params=params, verify=verify, **kwargs)
66-
if method == HTTPVerb.POST:
67-
response = sess.post(url, data=params, verify=verify, **kwargs)
61+
method = method.upper()
62+
if method == HTTPVerb.POST:
63+
response = self._session.post(url, data=params, verify=verify, **kwargs)
64+
else:
65+
response = self._session.get(url, params=params, verify=verify, **kwargs)
6866

6967
if response.status_code in [400, 429]:
7068
response_body = response.json()
7169
raise OpenMeteoRequestsError(response_body)
7270

7371
response.raise_for_status()
74-
return _process_response(response=response, handler=self._response_cls)
72+
return _process_response(response=response, handler=WeatherApiResponse)
7573

7674
def weather_api(
7775
self,
@@ -95,14 +93,18 @@ def weather_api(
9593
msg = f"failed to request {url!r}: {e}"
9694
raise OpenMeteoRequestsError(msg) from e
9795

96+
def __del__(self):
97+
"""cleanup"""
98+
if self._close_session:
99+
self._session.close()
100+
98101

99102
# pylint: disable=too-few-public-methods
100103
class AsyncClient:
101104
"""Asynchronous client for Open-Meteo API."""
102105

103106
def __init__(self, session: niquests.AsyncSession | None = None) -> None:
104-
self._session = session or niquests.AsyncSession()
105-
self._response_cls = WeatherApiResponse
107+
self._session = session
106108

107109
async def _request(
108110
self,
@@ -115,25 +117,24 @@ async def _request(
115117
) -> list[WeatherApiResponse]:
116118
params["format"] = _FLAT_BUFFERS_FORMAT
117119

118-
response: niquests.Response
119-
async with self._session as sess:
120-
method = method.upper()
121-
if method == HTTPVerb.GET:
122-
meth = partial(
123-
sess.get, url, params=params, verify=verify, **kwargs
124-
)
125-
if method == HTTPVerb.POST:
126-
meth = partial(
127-
sess.post, url, data=params, verify=verify, **kwargs
128-
)
129-
response = await meth()
120+
method = method.upper()
121+
if method == HTTPVerb.POST:
122+
if self._session:
123+
response = await self._session.post(url, data=params, verify=verify, **kwargs)
124+
else:
125+
response = await niquests.apost(url, data=params, verify=verify, **kwargs)
126+
else:
127+
if self._session:
128+
response = await self._session.get(url, params=params, verify=verify, **kwargs)
129+
else:
130+
response = await niquests.aget(url, params=params, verify=verify, **kwargs)
130131

131132
if response.status_code in [400, 429]:
132133
response_body = response.json()
133134
raise OpenMeteoRequestsError(response_body)
134135

135136
response.raise_for_status()
136-
return _process_response(response=response, handler=self._response_cls)
137+
return _process_response(response=response, handler=WeatherApiResponse)
137138

138139
async def weather_api(
139140
self,

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ classifiers = [
2323
]
2424
keywords = ["python", "open", "meteo", "weather", "client"]
2525
dependencies = [
26-
"niquests>=3.14.1",
26+
"niquests>=3.15.2",
2727
"openmeteo-sdk>=1.20.1",
2828
]
2929

@@ -90,7 +90,7 @@ asyncio_default_fixture_loop_scope = "function"
9090
asyncio_default_test_loop_scope = "function"
9191

9292
[tool.ruff]
93-
line-length = 80
93+
line-length = 100
9494
indent-width = 4
9595
target-version = "py39"
9696

tests/test_clients.py

Lines changed: 66 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,42 @@
55
from typing import Any
66

77
import pytest
8+
from niquests import AsyncSession, Session
89
from openmeteo_sdk.Variable import Variable
910

1011
from openmeteo_requests import AsyncClient, Client, OpenMeteoRequestsError
1112

1213

14+
def _process_fetchall_basic_responses(responses: list) -> None:
15+
assert len(responses) == 3
16+
response = responses[0]
17+
assert response.Latitude() == pytest.approx(52.5)
18+
assert response.Longitude() == pytest.approx(13.4)
19+
response = responses[1]
20+
assert response.Latitude() == pytest.approx(48.1)
21+
assert response.Longitude() == pytest.approx(9.3)
22+
response = responses[0]
23+
24+
hourly = response.Hourly()
25+
hourly_variables = list(map(lambda i: hourly.Variables(i), range(hourly.VariablesLength())))
26+
27+
temperature_2m = next(
28+
filter(
29+
lambda x: x.Variable() == Variable.temperature and x.Altitude() == 2,
30+
hourly_variables,
31+
)
32+
)
33+
precipitation = next(
34+
filter(
35+
lambda x: x.Variable() == Variable.precipitation,
36+
hourly_variables,
37+
)
38+
)
39+
40+
assert temperature_2m.ValuesLength() == 48
41+
assert precipitation.ValuesLength() == 48
42+
43+
1344
@pytest.fixture
1445
def client() -> Client:
1546
return Client()
@@ -44,90 +75,58 @@ def params() -> _ParamsType:
4475

4576

4677
class TestClient:
47-
def test_fetch_all(
48-
self, client: Client, url: str, params: _ParamsType
49-
) -> None:
78+
def test_fetch_all(self, client: Client, url: str, params: _ParamsType) -> None:
5079
responses = client.weather_api(url=url, params=params)
51-
52-
assert len(responses) == 3
53-
response = responses[0]
54-
assert response.Latitude() == pytest.approx(52.5)
55-
assert response.Longitude() == pytest.approx(13.4)
56-
response = responses[1]
57-
assert response.Latitude() == pytest.approx(48.1)
58-
assert response.Longitude() == pytest.approx(9.3)
59-
response = responses[0]
60-
61-
hourly = response.Hourly()
62-
hourly_variables = list(
63-
map(lambda i: hourly.Variables(i), range(hourly.VariablesLength()))
64-
)
65-
66-
temperature_2m = next(
67-
filter(
68-
lambda x: x.Variable() == Variable.temperature
69-
and x.Altitude() == 2,
70-
hourly_variables,
71-
)
72-
)
73-
precipitation = next(
74-
filter(
75-
lambda x: x.Variable() == Variable.precipitation,
76-
hourly_variables,
77-
)
78-
)
79-
80-
assert temperature_2m.ValuesLength() == 48
81-
assert precipitation.ValuesLength() == 48
80+
_process_fetchall_basic_responses(responses)
8281

8382
def test_empty_url_error(self, client: Client, params: _ParamsType) -> None:
8483
with pytest.raises(OpenMeteoRequestsError):
8584
client.weather_api(url="", params=params)
8685

86+
def test_sequential_requests_with_common_session(self, url: str, params: _ParamsType) -> None:
87+
session = Session()
88+
client = Client(session)
89+
try:
90+
# OK
91+
_process_fetchall_basic_responses(client.weather_api(url=url, params=params))
92+
# must not fail
93+
_process_fetchall_basic_responses(client.weather_api(url=url, params=params))
94+
finally:
95+
session.close()
96+
# expected result -> the session is already closed -> failure
97+
# actual result -> OK. Niquests seems to allow reuse of closed sessions
98+
client.weather_api(url=url, params=params)
99+
# with pytest.raises(OpenMeteoRequestsError):
100+
_process_fetchall_basic_responses(client.weather_api(url=url, params=params))
101+
87102

88103
@pytest.mark.asyncio
89104
class TestAsyncClient:
90105
async def test_async_fetch_all(
91106
self, async_client: AsyncClient, url: str, params: _ParamsType
92107
) -> None:
93108
responses = await async_client.weather_api(url=url, params=params)
109+
_process_fetchall_basic_responses(responses)
94110

95-
assert len(responses) == 3
96-
response = responses[0]
97-
assert response.Latitude() == pytest.approx(52.5)
98-
assert response.Longitude() == pytest.approx(13.4)
99-
response = responses[1]
100-
assert response.Latitude() == pytest.approx(48.1)
101-
assert response.Longitude() == pytest.approx(9.3)
102-
response = responses[0]
103-
104-
hourly = response.Hourly()
105-
hourly_variables = list(
106-
map(lambda i: hourly.Variables(i), range(hourly.VariablesLength()))
107-
)
108-
109-
temperature_2m = next(
110-
filter(
111-
lambda x: x.Variable() == Variable.temperature
112-
and x.Altitude() == 2,
113-
hourly_variables,
114-
)
115-
)
116-
precipitation = next(
117-
filter(
118-
lambda x: x.Variable() == Variable.precipitation,
119-
hourly_variables,
120-
)
121-
)
122-
123-
assert temperature_2m.ValuesLength() == 48
124-
assert precipitation.ValuesLength() == 48
111+
async def test_empty_url_error(self, async_client: AsyncClient, params: _ParamsType) -> None:
112+
with pytest.raises(OpenMeteoRequestsError):
113+
await async_client.weather_api(url="", params=params)
125114

126-
async def test_empty_url_error(
127-
self, async_client: AsyncClient, params: _ParamsType
115+
async def test_sequential_requests_with_common_session(
116+
self, url: str, params: _ParamsType
128117
) -> None:
118+
session = AsyncSession()
119+
client = AsyncClient(session)
120+
try:
121+
# OK
122+
_process_fetchall_basic_responses(await client.weather_api(url=url, params=params))
123+
# must not fail
124+
_process_fetchall_basic_responses(await client.weather_api(url=url, params=params))
125+
finally:
126+
await session.close()
127+
# The session is already closed -> failure
129128
with pytest.raises(OpenMeteoRequestsError):
130-
await async_client.weather_api(url="", params=params)
129+
_process_fetchall_basic_responses(await client.weather_api(url=url, params=params))
131130

132131

133132
def test_int_client():

0 commit comments

Comments
 (0)