Skip to content

Commit 8e551c3

Browse files
guillaume-dequennesonartech
authored andcommitted
SONARPY-2913 Rule S7483: Asynchronous functions should not accept timeout parameters (#258)
GitOrigin-RevId: 0823c2ee0f8d8319d7d225def5f507249cc054d9
1 parent bf24302 commit 8e551c3

9 files changed

Lines changed: 333 additions & 2 deletions

File tree

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
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+
package org.sonar.python.checks;
18+
19+
import org.sonar.check.Rule;
20+
import org.sonar.plugins.python.api.PythonSubscriptionCheck;
21+
import org.sonar.plugins.python.api.SubscriptionContext;
22+
import org.sonar.plugins.python.api.tree.FunctionDef;
23+
import org.sonar.plugins.python.api.tree.Name;
24+
import org.sonar.plugins.python.api.tree.Parameter;
25+
import org.sonar.plugins.python.api.tree.Token;
26+
import org.sonar.plugins.python.api.tree.Tree;
27+
import org.sonar.plugins.python.api.types.v2.ClassType;
28+
import org.sonar.plugins.python.api.types.v2.FunctionType;
29+
import org.sonar.python.tree.TreeUtils;
30+
31+
@Rule(key = "S7483")
32+
public class AsyncFunctionWithTimeoutCheck extends PythonSubscriptionCheck {
33+
34+
private static final String MESSAGE = "Remove this \"timeout\" parameter and use a timeout context manager instead.";
35+
36+
@Override
37+
public void initialize(Context context) {
38+
context.registerSyntaxNodeConsumer(Tree.Kind.FUNCDEF, AsyncFunctionWithTimeoutCheck::checkFunctionDef);
39+
}
40+
41+
private static void checkFunctionDef(SubscriptionContext ctx) {
42+
FunctionDef functionDef = (FunctionDef) ctx.syntaxNode();
43+
44+
// Check if the function is async using its token
45+
Token asyncKeyword = functionDef.asyncKeyword();
46+
if (asyncKeyword == null) {
47+
return;
48+
}
49+
50+
FunctionType functionType = (FunctionType) functionDef.name().typeV2();
51+
if (mightBeOverridingMethod(functionType)) {
52+
return;
53+
}
54+
55+
for (Parameter parameter : TreeUtils.nonTupleParameters(functionDef)) {
56+
Name parameterName = parameter.name();
57+
if (parameterName != null && "timeout".equals(parameterName.name())) {
58+
ctx.addIssue(parameter, MESSAGE).secondary(asyncKeyword, "This function is async.");
59+
return;
60+
}
61+
}
62+
}
63+
64+
private static boolean mightBeOverridingMethod(FunctionType functionType) {
65+
return functionType.owner() instanceof ClassType classType && (classType.hasUnresolvedHierarchy() || classType.inheritedMember(functionType.name()).isPresent());
66+
}
67+
}

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
@@ -121,6 +121,7 @@ public Stream<Class<?>> getChecks() {
121121
AssertOnDissimilarTypesCheck.class,
122122
AssertAfterRaiseCheck.class,
123123
AssertOnTupleLiteralCheck.class,
124+
AsyncFunctionWithTimeoutCheck.class,
124125
BackslashInStringCheck.class,
125126
BackticksUsageCheck.class,
126127
BareRaiseInFinallyCheck.class,
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
<p>This rule raises an issue when an asynchronous function accepts a <code>timeout</code> parameter instead of using the timeout context managers.</p>
2+
<h2>Why is this an issue?</h2>
3+
<p>Modern asynchronous Python libraries like <code>asyncio</code>, <code>anyio</code>, and <code>trio</code> promote a principle called
4+
<strong>structured concurrency</strong>. A key aspect of this is that the caller of an asynchronous function should be responsible for managing
5+
timeouts and cancellation, not the callee.</p>
6+
<p>When an <code>async</code> function accepts a <code>timeout</code> parameter, it violates this principle:</p>
7+
<ol>
8+
<li> <strong>Coupling between logic and timeout handling</strong>: The function dictates how the timeout is handled internally, rather than letting
9+
the caller decide. </li>
10+
<li> <strong>Preempting caller control</strong>: The caller might want to enforce a different timeout duration or coordinate timeouts across several
11+
concurrent operations. An internal timeout parameter makes this difficult or impossible. </li>
12+
<li> <strong>Reducing composability</strong>: Combining functions that manage their own timeouts can lead to complex and unpredictable behavior,
13+
especially when nesting calls or running tasks concurrently under a shared deadline. </li>
14+
</ol>
15+
<p>Instead, the caller should use the timeout features provided by the concurrency library (e.g., <code>async with asyncio.timeout()</code> or
16+
<code>with trio.move_on_after()</code>). This separates the concern of what the function does from how long the caller is willing to wait for it.</p>
17+
<h2>How to fix it in Asyncio</h2>
18+
<h3>Code examples</h3>
19+
<h4>Noncompliant code example</h4>
20+
<pre data-diff-id="1" data-diff-type="noncompliant">
21+
import asyncio
22+
23+
async def example_function(timeout): # Noncompliant
24+
await asyncio.sleep(timeout)
25+
26+
async def main():
27+
await example_function(5)
28+
</pre>
29+
<h4>Compliant solution</h4>
30+
<pre data-diff-id="1" data-diff-type="compliant">
31+
import asyncio
32+
33+
async def example_function():
34+
await asyncio.sleep(5)
35+
36+
async def main():
37+
async with asyncio.timeout(5): # Compliant
38+
await example_function()
39+
</pre>
40+
<h2>How to fix it in Trio</h2>
41+
<h3>Code examples</h3>
42+
<h4>Noncompliant code example</h4>
43+
<pre data-diff-id="2" data-diff-type="noncompliant">
44+
import trio
45+
46+
async def example_function(timeout): # Noncompliant
47+
await trio.sleep(timeout)
48+
49+
async def main():
50+
await example_function(5)
51+
52+
trio.run(main)
53+
</pre>
54+
<h4>Compliant solution</h4>
55+
<pre data-diff-id="2" data-diff-type="compliant">
56+
import trio
57+
58+
async def example_function():
59+
await trio.sleep(5)
60+
61+
async def main():
62+
with trio.move_on_after(5): # Compliant
63+
await example_function()
64+
65+
trio.run(main)
66+
</pre>
67+
<h2>How to fix it in AnyIO</h2>
68+
<h3>Code examples</h3>
69+
<h4>Noncompliant code example</h4>
70+
<pre data-diff-id="3" data-diff-type="noncompliant">
71+
import anyio
72+
73+
async def example_function(timeout): # Noncompliant
74+
await anyio.sleep(timeout)
75+
76+
async def main():
77+
await example_function(5)
78+
79+
anyio.run(main)
80+
</pre>
81+
<h4>Compliant solution</h4>
82+
<pre data-diff-id="3" data-diff-type="compliant">
83+
import anyio
84+
85+
async def example_function():
86+
await anyio.sleep(5)
87+
88+
async def main():
89+
with anyio.move_on_after(5): # Compliant
90+
await example_function()
91+
92+
anyio.run(main)
93+
</pre>
94+
<h2>Resources</h2>
95+
<h3>Documentation</h3>
96+
<ul>
97+
<li> Asyncio documentation - <a href="https://docs.python.org/3/library/asyncio-task.html#asyncio.timeout">Timeouts</a> </li>
98+
<li> Trio documentation - <a href="https://trio.readthedocs.io/en/stable/reference-core.html#cancellation-and-timeouts">Cancellation and
99+
timeouts</a> </li>
100+
<li> AnyIO documentation - <a href="https://anyio.readthedocs.io/en/stable/cancellation.html#timeouts">Timeouts</a> </li>
101+
</ul>
102+
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"title": "Asynchronous functions should not accept timeout parameters",
3+
"type": "CODE_SMELL",
4+
"status": "ready",
5+
"remediation": {
6+
"func": "Constant\/Issue",
7+
"constantCost": "5min"
8+
},
9+
"tags": [
10+
"async",
11+
"asyncio",
12+
"anyio",
13+
"trio"
14+
],
15+
"defaultSeverity": "Major",
16+
"ruleSpecification": "RSPEC-7483",
17+
"sqKey": "S7483",
18+
"scope": "All",
19+
"quickfix": "infeasible",
20+
"code": {
21+
"impacts": {
22+
"MAINTAINABILITY": "MEDIUM"
23+
},
24+
"attribute": "FOCUSED"
25+
}
26+
}

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,8 @@
255255
"S6984",
256256
"S6985",
257257
"S7488",
258-
"S7491"
258+
"S7491",
259+
"S7483",
260+
"S7488"
259261
]
260262
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
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+
package org.sonar.python.checks;
18+
19+
import org.junit.jupiter.api.Test;
20+
import org.sonar.python.checks.utils.PythonCheckVerifier;
21+
22+
class AsyncFunctionWithTimeoutCheckTest {
23+
24+
@Test
25+
void test() {
26+
PythonCheckVerifier.verify("src/test/resources/checks/asyncFunctionWithTimeout.py", new AsyncFunctionWithTimeoutCheck());
27+
}
28+
29+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import asyncio
2+
import trio
3+
import anyio
4+
5+
6+
class AsyncioClass:
7+
async def method_noncompliant(self, timeout): # Noncompliant {{Remove this "timeout" parameter and use a timeout context manager instead.}}
8+
# ^^^^^^^
9+
# ^^^^^@-1< {{This function is async.}}
10+
await asyncio.sleep(1)
11+
12+
async def method_compliant(self):
13+
await asyncio.sleep(1)
14+
15+
def sync_method_with_timeout(self, timeout): # Compliant
16+
pass
17+
18+
# Test cases for asyncio
19+
async def noncompliant_simple(timeout): # Noncompliant
20+
await asyncio.sleep(1)
21+
22+
async def noncompliant_with_typehint(timeout: int): # Noncompliant
23+
await asyncio.sleep(1)
24+
25+
async def noncompliant_with_default(timeout=5): # Noncompliant
26+
await asyncio.sleep(1)
27+
28+
async def noncompliant_keyword_only(param, *, timeout): # Noncompliant
29+
await asyncio.sleep(1)
30+
31+
async def noncompliant_positional_only(timeout, /, param): # Noncompliant
32+
await asyncio.sleep(1)
33+
34+
async def timeout_different_name(custom_timeout): # OK
35+
await asyncio.sleep(1)
36+
37+
async def noncompliant_multiple_params(arg1, timeout, arg2): # Noncompliant
38+
await asyncio.sleep(1)
39+
40+
async def noncompliant_star_args(arg1, *args, timeout): # Noncompliant
41+
await asyncio.sleep(1)
42+
43+
async def noncompliant_star_kwargs(arg1, timeout, **kwargs): # Noncompliant
44+
await asyncio.sleep(1)
45+
46+
async def compliant_no_timeout_param():
47+
await asyncio.sleep(1)
48+
49+
async def compliant_timeout_handled_by_caller():
50+
await asyncio.sleep(1)
51+
52+
async def main_compliant():
53+
async with asyncio.timeout(5):
54+
await compliant_timeout_handled_by_caller()
55+
56+
def sync_function_with_timeout_param(timeout): # Compliant
57+
pass
58+
59+
async def timeout_in_kwargs(**kwargs): # FN
60+
duration = kwargs.get("timeout", 1)
61+
await asyncio.sleep(duration)
62+
63+
# Nested functions
64+
65+
def outer_function():
66+
async def nested_function_noncompliant(timeout): # Noncompliant
67+
await asyncio.sleep(timeout)
68+
await nested_function_noncompliant(5)
69+
70+
async def outer_function():
71+
def nested_function_noncompliant(timeout): # OK
72+
foo()
73+
await something()
74+
75+
76+
# Inheritance
77+
78+
class ClassWithUnknownParent(Unknown):
79+
async def method_with_timeout(self, timeout): # OK, may be inherited
80+
await something()
81+
82+
class KnownClass:
83+
...
84+
85+
class ClassWithKnownParent(KnownClass):
86+
async def method_with_timeout(self, timeout): # Noncompliant
87+
await something()
88+
89+
class KnownClassWithTimeout:
90+
async def method_with_timeout(self, timeout): # Noncompliant
91+
await something()
92+
93+
class ClassWithKnownParent(KnownClassWithTimeout):
94+
async def method_with_timeout(self, timeout): # OK, inherited
95+
await something_else()
96+

python-frontend/src/main/java/org/sonar/plugins/python/api/types/v2/ClassType.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,10 @@ private Optional<PythonType> localMember(String memberName) {
144144
.findFirst();
145145
}
146146

147+
public Optional<PythonType> inheritedMember(String memberName) {
148+
return inheritedMember(memberName, new HashSet<>());
149+
}
150+
147151
private Optional<PythonType> inheritedMember(String memberName, Set<PythonType> visited) {
148152
return superClasses().stream()
149153
.map(TypeWrapper::type)

python-frontend/src/test/java/org/sonar/plugins/python/api/types/v2/ClassTypeTest.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -465,10 +465,14 @@ void class_members_with_inheritance() {
465465
assertThat(classB.members()).hasSize(1);
466466
ClassType classA = (ClassType) classB.superClasses().get(0).type();
467467
assertThat(classA.members()).hasSize(1);
468+
PythonType meth = classA.resolveMember("meth").get();
469+
assertThat(meth).isInstanceOf(FunctionType.class);
468470

469471
assertThat(classB.resolveMember("foo")).isPresent();
470472
assertThat(classB.resolveMember("meth")).isPresent();
471-
assertThat(classB.resolveMember("unkown")).isNotPresent();
473+
assertThat(classB.resolveMember("unknown")).isNotPresent();
474+
assertThat(classB.inheritedMember("foo")).isEmpty();
475+
assertThat(classB.inheritedMember("meth")).contains(meth);
472476
}
473477

474478
@Test

0 commit comments

Comments
 (0)