Skip to content

Commit af259fa

Browse files
authored
[flake8-async] Implement blocking-http-call-httpx (ASYNC212) (#20091)
## Summary Adds new rule to find and report use of `httpx.Client` in synchronous functions. See issue #8451 ## Test Plan New snapshots for `ASYNC212.py` with `cargo insta test`.
1 parent d75ef38 commit af259fa

File tree

8 files changed

+396
-0
lines changed

8 files changed

+396
-0
lines changed
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
from typing import Optional
2+
3+
import httpx
4+
5+
6+
def foo():
7+
client = httpx.Client()
8+
client.close() # Ok
9+
client.delete() # Ok
10+
client.get() # Ok
11+
client.head() # Ok
12+
client.options() # Ok
13+
client.patch() # Ok
14+
client.post() # Ok
15+
client.put() # Ok
16+
client.request() # Ok
17+
client.send() # Ok
18+
client.stream() # Ok
19+
20+
client.anything() # Ok
21+
client.build_request() # Ok
22+
client.is_closed # Ok
23+
24+
25+
async def foo():
26+
client = httpx.Client()
27+
client.close() # ASYNC212
28+
client.delete() # ASYNC212
29+
client.get() # ASYNC212
30+
client.head() # ASYNC212
31+
client.options() # ASYNC212
32+
client.patch() # ASYNC212
33+
client.post() # ASYNC212
34+
client.put() # ASYNC212
35+
client.request() # ASYNC212
36+
client.send() # ASYNC212
37+
client.stream() # ASYNC212
38+
39+
client.anything() # Ok
40+
client.build_request() # Ok
41+
client.is_closed # Ok
42+
43+
44+
async def foo(client: httpx.Client):
45+
client.request() # ASYNC212
46+
client.anything() # Ok
47+
48+
49+
async def foo(client: httpx.Client | None):
50+
client.request() # ASYNC212
51+
client.anything() # Ok
52+
53+
54+
async def foo(client: Optional[httpx.Client]):
55+
client.request() # ASYNC212
56+
client.anything() # Ok
57+
58+
59+
async def foo():
60+
client: httpx.Client = ...
61+
client.request() # ASYNC212
62+
client.anything() # Ok
63+
64+
65+
global_client = httpx.Client()
66+
67+
68+
async def foo():
69+
global_client.request() # ASYNC212
70+
global_client.anything() # Ok
71+
72+
73+
async def foo():
74+
async with httpx.AsyncClient() as client:
75+
await client.get() # Ok

crates/ruff_linter/src/checkers/ast/analyze/expression.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -660,6 +660,9 @@ pub(crate) fn expression(expr: &Expr, checker: &Checker) {
660660
if checker.is_rule_enabled(Rule::BlockingHttpCallInAsyncFunction) {
661661
flake8_async::rules::blocking_http_call(checker, call);
662662
}
663+
if checker.is_rule_enabled(Rule::BlockingHttpCallHttpxInAsyncFunction) {
664+
flake8_async::rules::blocking_http_call_httpx(checker, call);
665+
}
663666
if checker.is_rule_enabled(Rule::BlockingOpenCallInAsyncFunction) {
664667
flake8_async::rules::blocking_open_call(checker, call);
665668
}

crates/ruff_linter/src/codes.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,7 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> {
336336
(Flake8Async, "115") => (RuleGroup::Stable, rules::flake8_async::rules::AsyncZeroSleep),
337337
(Flake8Async, "116") => (RuleGroup::Preview, rules::flake8_async::rules::LongSleepNotForever),
338338
(Flake8Async, "210") => (RuleGroup::Stable, rules::flake8_async::rules::BlockingHttpCallInAsyncFunction),
339+
(Flake8Async, "212") => (RuleGroup::Preview, rules::flake8_async::rules::BlockingHttpCallHttpxInAsyncFunction),
339340
(Flake8Async, "220") => (RuleGroup::Stable, rules::flake8_async::rules::CreateSubprocessInAsyncFunction),
340341
(Flake8Async, "221") => (RuleGroup::Stable, rules::flake8_async::rules::RunProcessInAsyncFunction),
341342
(Flake8Async, "222") => (RuleGroup::Stable, rules::flake8_async::rules::WaitForProcessInAsyncFunction),

crates/ruff_linter/src/rules/flake8_async/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ mod tests {
2323
#[test_case(Rule::AsyncZeroSleep, Path::new("ASYNC115.py"))]
2424
#[test_case(Rule::LongSleepNotForever, Path::new("ASYNC116.py"))]
2525
#[test_case(Rule::BlockingHttpCallInAsyncFunction, Path::new("ASYNC210.py"))]
26+
#[test_case(Rule::BlockingHttpCallHttpxInAsyncFunction, Path::new("ASYNC212.py"))]
2627
#[test_case(Rule::CreateSubprocessInAsyncFunction, Path::new("ASYNC22x.py"))]
2728
#[test_case(Rule::RunProcessInAsyncFunction, Path::new("ASYNC22x.py"))]
2829
#[test_case(Rule::WaitForProcessInAsyncFunction, Path::new("ASYNC22x.py"))]
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
use ruff_python_ast::{self as ast, Expr, ExprCall};
2+
3+
use ruff_macros::{ViolationMetadata, derive_message_formats};
4+
use ruff_python_semantic::analyze::typing::{TypeChecker, check_type, traverse_union_and_optional};
5+
use ruff_text_size::Ranged;
6+
7+
use crate::Violation;
8+
use crate::checkers::ast::Checker;
9+
10+
/// ## What it does
11+
/// Checks that async functions do not use blocking httpx clients.
12+
///
13+
/// ## Why is this bad?
14+
/// Blocking an async function via a blocking HTTP call will block the entire
15+
/// event loop, preventing it from executing other tasks while waiting for the
16+
/// HTTP response, negating the benefits of asynchronous programming.
17+
///
18+
/// Instead of using the blocking `httpx` client, use the asynchronous client.
19+
///
20+
/// ## Example
21+
/// ```python
22+
/// import httpx
23+
///
24+
///
25+
/// async def fetch():
26+
/// client = httpx.Client()
27+
/// response = client.get(...)
28+
/// ```
29+
///
30+
/// Use instead:
31+
/// ```python
32+
/// import httpx
33+
///
34+
///
35+
/// async def fetch():
36+
/// async with httpx.AsyncClient() as client:
37+
/// response = await client.get(...)
38+
/// ```
39+
#[derive(ViolationMetadata)]
40+
pub(crate) struct BlockingHttpCallHttpxInAsyncFunction {
41+
name: String,
42+
call: String,
43+
}
44+
45+
impl Violation for BlockingHttpCallHttpxInAsyncFunction {
46+
#[derive_message_formats]
47+
fn message(&self) -> String {
48+
format!(
49+
"Blocking httpx method {name}.{call}() in async context, use httpx.AsyncClient",
50+
name = self.name,
51+
call = self.call,
52+
)
53+
}
54+
}
55+
56+
struct HttpxClientChecker;
57+
58+
impl TypeChecker for HttpxClientChecker {
59+
fn match_annotation(
60+
annotation: &ruff_python_ast::Expr,
61+
semantic: &ruff_python_semantic::SemanticModel,
62+
) -> bool {
63+
// match base annotation directly
64+
if semantic
65+
.resolve_qualified_name(annotation)
66+
.is_some_and(|qualified_name| matches!(qualified_name.segments(), ["httpx", "Client"]))
67+
{
68+
return true;
69+
}
70+
71+
// otherwise traverse any union or optional annotation
72+
let mut found = false;
73+
traverse_union_and_optional(
74+
&mut |inner_expr, _| {
75+
if semantic
76+
.resolve_qualified_name(inner_expr)
77+
.is_some_and(|qualified_name| {
78+
matches!(qualified_name.segments(), ["httpx", "Client"])
79+
})
80+
{
81+
found = true;
82+
}
83+
},
84+
semantic,
85+
annotation,
86+
);
87+
found
88+
}
89+
90+
fn match_initializer(
91+
initializer: &ruff_python_ast::Expr,
92+
semantic: &ruff_python_semantic::SemanticModel,
93+
) -> bool {
94+
let Expr::Call(ExprCall { func, .. }) = initializer else {
95+
return false;
96+
};
97+
98+
semantic
99+
.resolve_qualified_name(func)
100+
.is_some_and(|qualified_name| matches!(qualified_name.segments(), ["httpx", "Client"]))
101+
}
102+
}
103+
104+
/// ASYNC212
105+
pub(crate) fn blocking_http_call_httpx(checker: &Checker, call: &ExprCall) {
106+
let semantic = checker.semantic();
107+
if !semantic.in_async_context() {
108+
return;
109+
}
110+
111+
let Some(ast::ExprAttribute { value, attr, .. }) = call.func.as_attribute_expr() else {
112+
return;
113+
};
114+
let Some(name) = value.as_name_expr() else {
115+
return;
116+
};
117+
let Some(binding) = semantic.only_binding(name).map(|id| semantic.binding(id)) else {
118+
return;
119+
};
120+
121+
if check_type::<HttpxClientChecker>(binding, semantic) {
122+
if matches!(
123+
attr.id.as_str(),
124+
"close"
125+
| "delete"
126+
| "get"
127+
| "head"
128+
| "options"
129+
| "patch"
130+
| "post"
131+
| "put"
132+
| "request"
133+
| "send"
134+
| "stream"
135+
) {
136+
checker.report_diagnostic(
137+
BlockingHttpCallHttpxInAsyncFunction {
138+
name: name.id.to_string(),
139+
call: attr.id.to_string(),
140+
},
141+
call.func.range(),
142+
);
143+
}
144+
}
145+
}

crates/ruff_linter/src/rules/flake8_async/rules/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ pub(crate) use async_busy_wait::*;
22
pub(crate) use async_function_with_timeout::*;
33
pub(crate) use async_zero_sleep::*;
44
pub(crate) use blocking_http_call::*;
5+
pub(crate) use blocking_http_call_httpx::*;
56
pub(crate) use blocking_open_call::*;
67
pub(crate) use blocking_process_invocation::*;
78
pub(crate) use blocking_sleep::*;
@@ -13,6 +14,7 @@ mod async_busy_wait;
1314
mod async_function_with_timeout;
1415
mod async_zero_sleep;
1516
mod blocking_http_call;
17+
mod blocking_http_call_httpx;
1618
mod blocking_open_call;
1719
mod blocking_process_invocation;
1820
mod blocking_sleep;

0 commit comments

Comments
 (0)