Skip to content

Commit b3cec28

Browse files
ghislainpiotsonartech
authored andcommitted
SONARPY-2932 Rule S7499: Async functions should not contain synchronous http operations (#263)
GitOrigin-RevId: 566679fd06efa9f71ae819e5900d2c23c3d6845f
1 parent 2c7f5aa commit b3cec28

8 files changed

Lines changed: 377 additions & 0 deletions

File tree

python-checks/src/main/java/org/sonar/python/checks/OpenSourceCheckList.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,7 @@ public Stream<Class<?>> getChecks() {
357357
SklearnPipelineParameterAreCorrectCheck.class,
358358
SuperfluousCurlyBraceCheck.class,
359359
TempFileCreationCheck.class,
360+
SynchronousHttpOperationsInAsyncCheck.class,
360361
ImplicitlySkippedTestCheck.class,
361362
TimeSleepInAsyncCheck.class,
362363
ToDoCommentCheck.class,
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/*
2+
* SonarQube Python Plugin
3+
* Copyright (C) 2011-2025 SonarSource SA
4+
* mailto:info AT sonarsource DOT com
5+
*
6+
* This program is free software; you can redistribute it and/or
7+
* modify it under the terms of the Sonar Source-Available License Version 1, as published by SonarSource SA.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
12+
* See the Sonar Source-Available License for more details.
13+
*
14+
* You should have received a copy of the Sonar Source-Available License
15+
* along with this program; if not, see https://sonarsource.com/license/ssal/
16+
*/
17+
18+
package org.sonar.python.checks;
19+
20+
import java.util.Set;
21+
22+
import org.sonar.check.Rule;
23+
import org.sonar.plugins.python.api.PythonSubscriptionCheck;
24+
import org.sonar.plugins.python.api.SubscriptionContext;
25+
import org.sonar.plugins.python.api.tree.CallExpression;
26+
import org.sonar.plugins.python.api.tree.Tree;
27+
import org.sonar.python.tree.TreeUtils;
28+
import org.sonar.python.types.v2.TypeCheckMap;
29+
30+
@Rule(key = "S7499")
31+
public class SynchronousHttpOperationsInAsyncCheck extends PythonSubscriptionCheck {
32+
private static final String MESSAGE = "Use an async HTTP client in this async function instead of a synchronous one.";
33+
private static final String SECONDARY_MESSAGE = "This function is async.";
34+
35+
private static final Set<String> IMPORT_PATHS = Set.of(
36+
"requests.get",
37+
"requests.post",
38+
"requests.put",
39+
"requests.delete",
40+
"requests.head",
41+
"requests.options",
42+
"requests.patch",
43+
"requests.sessions.Session.get",
44+
"requests.sessions.Session.post",
45+
"requests.sessions.Session.put",
46+
"requests.sessions.Session.delete",
47+
"requests.sessions.Session.head",
48+
"requests.sessions.Session.options",
49+
"requests.sessions.Session.patch",
50+
"urllib3.PoolManager",
51+
"urllib3.PoolManager.request");
52+
53+
private static final Set<String> IMPORT_PATHS_FQN = Set.of(
54+
"httpx.get",
55+
"httpx.post",
56+
"httpx.put",
57+
"httpx.delete",
58+
"httpx.head",
59+
"httpx.options",
60+
"httpx.patch");
61+
62+
private final TypeCheckMap<Object> syncHttpTypeChecks = new TypeCheckMap<>();
63+
64+
@Override
65+
public void initialize(Context context) {
66+
context.registerSyntaxNodeConsumer(Tree.Kind.FILE_INPUT, this::setupTypeChecks);
67+
context.registerSyntaxNodeConsumer(Tree.Kind.CALL_EXPR, this::checkSyncHttpInAsync);
68+
}
69+
70+
private void setupTypeChecks(SubscriptionContext ctx) {
71+
var object = new Object();
72+
IMPORT_PATHS.forEach(path -> syncHttpTypeChecks.put(ctx.typeChecker().typeCheckBuilder().isTypeOrInstanceWithName(path), object));
73+
IMPORT_PATHS_FQN.forEach(path -> syncHttpTypeChecks.put(ctx.typeChecker().typeCheckBuilder().isTypeWithFqn(path), object));
74+
}
75+
76+
private void checkSyncHttpInAsync(SubscriptionContext ctx) {
77+
var callExpression = (CallExpression) ctx.syntaxNode();
78+
var asyncToken = TreeUtils.asyncTokenOfEnclosingFunction(callExpression).orElse(null);
79+
if (asyncToken == null) {
80+
return;
81+
}
82+
83+
syncHttpTypeChecks.getOptionalForType(callExpression.callee().typeV2())
84+
.ifPresent(object -> ctx.addIssue(callExpression.callee(), MESSAGE).secondary(asyncToken, SECONDARY_MESSAGE));
85+
}
86+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<p>This rule raises an issue when synchronous HTTP client calls are used within asynchronous functions.</p>
2+
<h2>Why is this an issue?</h2>
3+
<p>Using synchronous HTTP clients like <code>urllib3</code>, <code>requests</code>, or <code>httpx.Client</code> in asynchronous code blocks the
4+
entire event loop. This undermines the primary advantage of asynchronous programming - the ability to perform concurrent operations without blocking
5+
execution.</p>
6+
<p>When an async function makes a synchronous HTTP request:</p>
7+
<ul>
8+
<li> The event loop is completely blocked until the HTTP operation completes </li>
9+
<li> No other coroutines can run during this time, even if they’re ready to execute </li>
10+
<li> The responsiveness of the application is degraded </li>
11+
<li> In server applications, this can cause timeouts or failures for other concurrent requests </li>
12+
</ul>
13+
<p>Instead, async-compatible HTTP clients should be used:</p>
14+
<ul>
15+
<li> <code>httpx.AsyncClient</code> - works with asyncio, Trio, and AnyIO </li>
16+
<li> <code>aiohttp.ClientSession</code> - works with asyncio </li>
17+
<li> <code>asks</code> - works with Trio and asyncio </li>
18+
</ul>
19+
<p>Using these libraries allows other tasks to continue executing while waiting for HTTP responses, significantly improving application performance
20+
and responsiveness.</p>
21+
<h2>How to fix it</h2>
22+
<p>Replace synchronous HTTP clients with asynchronous alternatives. The <code>httpx.AsyncClient</code> is recommended as it provides a consistent API
23+
across asyncio, Trio, and AnyIO frameworks.</p>
24+
<h3>Code examples</h3>
25+
<h4>Noncompliant code example</h4>
26+
<pre data-diff-id="1" data-diff-type="noncompliant">
27+
import requests
28+
29+
async def fetch_data():
30+
response = requests.get("https://api.example.com/data") # Noncompliant
31+
return response.json()
32+
</pre>
33+
<h4>Compliant solution</h4>
34+
<p>Using httpx.AsyncClient (works with asyncio, Trio, and AnyIO):</p>
35+
<pre data-diff-id="1" data-diff-type="compliant">
36+
import httpx
37+
38+
async def fetch_data():
39+
async with httpx.AsyncClient() as client:
40+
response = await client.get("https://api.example.com/data")
41+
return response.json()
42+
</pre>
43+
<h2>Resources</h2>
44+
<h3>Documentation</h3>
45+
<ul>
46+
<li> HTTPX - <a href="https://www.python-httpx.org/async/">Async Usage</a> </li>
47+
<li> AIOHTTP - <a href="https://docs.aiohttp.org/en/stable/client_quickstart.html">Client Quickstart</a> </li>
48+
<li> Asks - <a href="https://asks.readthedocs.io/en/latest/">Documentation</a> </li>
49+
</ul>
50+
<h3>Articles &amp; blog posts</h3>
51+
<ul>
52+
<li> Python - <a href="https://realpython.com/async-io-python/">Async IO in Python</a> </li>
53+
</ul>
54+
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
{
2+
"title": "Async functions should not contain synchronous HTTP client calls",
3+
"type": "BUG",
4+
"status": "ready",
5+
"remediation": {
6+
"func": "Constant\/Issue",
7+
"constantCost": "5min"
8+
},
9+
"tags": [
10+
"async",
11+
"asyncio",
12+
"anyio",
13+
"trio",
14+
"http"
15+
],
16+
"defaultSeverity": "Major",
17+
"ruleSpecification": "RSPEC-7499",
18+
"sqKey": "S7499",
19+
"scope": "All",
20+
"quickfix": "unknown",
21+
"code": {
22+
"impacts": {
23+
"RELIABILITY": "HIGH"
24+
},
25+
"attribute": "EFFICIENT"
26+
}
27+
}

python-checks/src/main/resources/org/sonar/l10n/py/rules/python/Sonar_way_profile.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,10 +254,13 @@
254254
"S6983",
255255
"S6984",
256256
"S6985",
257+
"S7483",
258+
"S7488",
257259
"S7488",
258260
"S7491",
259261
"S7483",
260262
"S7488",
263+
"S7499",
261264
"S7501"
262265
]
263266
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/*
2+
* SonarQube Python Plugin
3+
* Copyright (C) 2011-2025 SonarSource SA
4+
* mailto:info AT sonarsource DOT com
5+
*
6+
* This program is free software; you can redistribute it and/or
7+
* modify it under the terms of the Sonar Source-Available License Version 1, as published by SonarSource SA.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
12+
* See the Sonar Source-Available License for more details.
13+
*
14+
* You should have received a copy of the Sonar Source-Available License
15+
* along with this program; if not, see https://sonarsource.com/license/ssal/
16+
*/
17+
18+
package org.sonar.python.checks;
19+
20+
import org.junit.jupiter.api.Test;
21+
import org.sonar.python.checks.utils.PythonCheckVerifier;
22+
23+
class SynchronousHttpOperationsInAsyncCheckTest {
24+
25+
@Test
26+
void test() {
27+
PythonCheckVerifier.verify("src/test/resources/checks/synchronousHttpOperationsInAsync.py", new SynchronousHttpOperationsInAsyncCheck());
28+
}
29+
30+
}
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
2+
3+
# Test cases for requests library
4+
async def test_requests_sync_client():
5+
import requests
6+
7+
response = requests.get("https://example.com") # Noncompliant
8+
9+
response = requests.post("https://example.com", data={"key": "value"}) # Noncompliant
10+
11+
session = requests.Session()
12+
response = session.get("https://example.com") # Noncompliant
13+
14+
# Methods test
15+
requests.delete("https://example.com") # Noncompliant
16+
requests.head("https://example.com") # Noncompliant
17+
requests.options("https://example.com") # Noncompliant
18+
requests.patch("https://example.com") # Noncompliant
19+
requests.put("https://example.com") # Noncompliant
20+
21+
async def nesting():
22+
import requests
23+
async def inner_function():
24+
# ^^^^^> {{This function is async.}}
25+
requests.get("https://example.com") # Noncompliant {{Use an async HTTP client in this async function instead of a synchronous one.}}
26+
# ^^^^^^^^^^^^
27+
28+
# Test cases for urllib3 library
29+
async def test_urllib3_sync_client():
30+
import urllib3
31+
32+
http = urllib3.PoolManager() # Noncompliant
33+
response = http.request("GET", "https://example.com") # Noncompliant
34+
35+
response = http.request_encode_url("GET", "https://example.com") # FN
36+
37+
response = http.request_encode_body("POST", "https://example.com", fields={"key": "value"}) # FN
38+
39+
# Test cases for httpx synchronous client
40+
async def test_httpx_sync_client():
41+
import httpx
42+
43+
response = httpx.get("https://example.com") # Noncompliant
44+
45+
client = httpx.Client()
46+
response = client.get("https://example.com") # FN SONARPY-2965
47+
48+
with httpx.Client() as client:
49+
response = client.get("https://example.com") # FN SONARPY-2965
50+
51+
# Test compliant cases with async HTTP clients
52+
async def test_compliant_async_clients():
53+
# httpx async client
54+
import httpx
55+
56+
async with httpx.AsyncClient() as client:
57+
response = await client.get("https://example.com")
58+
59+
client = httpx.AsyncClient()
60+
response = await client.get("https://example.com")
61+
62+
# aiohttp client
63+
import aiohttp
64+
65+
async with aiohttp.ClientSession() as session:
66+
async with session.get("https://example.com") as response:
67+
data = await response.text()
68+
69+
# asks library
70+
import asks
71+
72+
response = await asks.get("https://example.com")
73+
74+
# Test nested async functions
75+
async def test_nested_async_functions():
76+
import requests
77+
78+
async def inner_function():
79+
return requests.get("https://example.com") # Noncompliant
80+
81+
result = await inner_function()
82+
83+
# Test functions that use synchronous HTTP clients but are not async themselves
84+
def test_sync_function_with_sync_client():
85+
import requests
86+
87+
response = requests.get("https://example.com") # Compliant - not in async function
88+
89+
# Test for method calls on returned objects
90+
async def test_method_chaining():
91+
import requests
92+
93+
data = requests.get("https://example.com").json() # Noncompliant
94+
95+
import urllib3
96+
http = urllib3.PoolManager() # Noncompliant
97+
data = http.request("GET", "https://example.com").data # Noncompliant
98+
99+
# Test for assignments to variables and complex expressions
100+
async def test_complex_assignments():
101+
import requests
102+
103+
# Assignment to variable
104+
response = requests.get("https://example.com") # Noncompliant
105+
106+
# In a complex expression
107+
data = [requests.get(f"https://example.com/{i}").json() for i in range(5)] # Noncompliant
108+
109+
# As function argument
110+
process_response(requests.get("https://example.com")) # Noncompliant
111+
112+
# Test for HTTP client in async generators
113+
async def test_async_generator():
114+
import requests
115+
116+
async def async_generator():
117+
for i in range(5):
118+
yield requests.get(f"https://example.com/{i}") # Noncompliant
119+
120+
async for response in async_generator():
121+
print(response.text)
122+
123+
# Test for HTTP clients created outside async function but used inside
124+
async def test_client_created_outside():
125+
import httpx
126+
client = httpx.Client()
127+
128+
response = client.get("https://example.com") # FN
129+
130+
# Compliant case with async client
131+
async_client = httpx.AsyncClient()
132+
response = await async_client.get("https://example.com")
133+
134+
# Test for conditional usage of HTTP clients
135+
async def test_conditional_usage():
136+
import requests
137+
import httpx
138+
139+
use_async = False
140+
141+
if use_async:
142+
async with httpx.AsyncClient() as client:
143+
response = await client.get("https://example.com")
144+
else:
145+
response = requests.get("https://example.com") # Noncompliant
146+
147+
# Test for try/except blocks
148+
async def test_try_except():
149+
import requests
150+
151+
try:
152+
response = requests.get("https://example.com") # Noncompliant
153+
except Exception:
154+
pass
155+
156+
# Edge cases to ensure no false positives
157+
async def test_edge_cases():
158+
# String containing 'requests.get' should not trigger
159+
code_string = "response = requests.get('https://example.com')"
160+
161+
# Variable named 'requests' should not trigger if it's not the actual requests module
162+
class MockRequests:
163+
def get(self, url):
164+
return None
165+
166+
requests = MockRequests()
167+
response = requests.get("https://example.com") # Should not trigger

0 commit comments

Comments
 (0)