[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`.
This commit is contained in:
Amethyst Reese 2025-08-27 15:19:01 -07:00 committed by GitHub
parent d75ef3823c
commit af259faed5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 396 additions and 0 deletions

View file

@ -0,0 +1,75 @@
from typing import Optional
import httpx
def foo():
client = httpx.Client()
client.close() # Ok
client.delete() # Ok
client.get() # Ok
client.head() # Ok
client.options() # Ok
client.patch() # Ok
client.post() # Ok
client.put() # Ok
client.request() # Ok
client.send() # Ok
client.stream() # Ok
client.anything() # Ok
client.build_request() # Ok
client.is_closed # Ok
async def foo():
client = httpx.Client()
client.close() # ASYNC212
client.delete() # ASYNC212
client.get() # ASYNC212
client.head() # ASYNC212
client.options() # ASYNC212
client.patch() # ASYNC212
client.post() # ASYNC212
client.put() # ASYNC212
client.request() # ASYNC212
client.send() # ASYNC212
client.stream() # ASYNC212
client.anything() # Ok
client.build_request() # Ok
client.is_closed # Ok
async def foo(client: httpx.Client):
client.request() # ASYNC212
client.anything() # Ok
async def foo(client: httpx.Client | None):
client.request() # ASYNC212
client.anything() # Ok
async def foo(client: Optional[httpx.Client]):
client.request() # ASYNC212
client.anything() # Ok
async def foo():
client: httpx.Client = ...
client.request() # ASYNC212
client.anything() # Ok
global_client = httpx.Client()
async def foo():
global_client.request() # ASYNC212
global_client.anything() # Ok
async def foo():
async with httpx.AsyncClient() as client:
await client.get() # Ok

View file

@ -660,6 +660,9 @@ pub(crate) fn expression(expr: &Expr, checker: &Checker) {
if checker.is_rule_enabled(Rule::BlockingHttpCallInAsyncFunction) {
flake8_async::rules::blocking_http_call(checker, call);
}
if checker.is_rule_enabled(Rule::BlockingHttpCallHttpxInAsyncFunction) {
flake8_async::rules::blocking_http_call_httpx(checker, call);
}
if checker.is_rule_enabled(Rule::BlockingOpenCallInAsyncFunction) {
flake8_async::rules::blocking_open_call(checker, call);
}

View file

@ -336,6 +336,7 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> {
(Flake8Async, "115") => (RuleGroup::Stable, rules::flake8_async::rules::AsyncZeroSleep),
(Flake8Async, "116") => (RuleGroup::Preview, rules::flake8_async::rules::LongSleepNotForever),
(Flake8Async, "210") => (RuleGroup::Stable, rules::flake8_async::rules::BlockingHttpCallInAsyncFunction),
(Flake8Async, "212") => (RuleGroup::Preview, rules::flake8_async::rules::BlockingHttpCallHttpxInAsyncFunction),
(Flake8Async, "220") => (RuleGroup::Stable, rules::flake8_async::rules::CreateSubprocessInAsyncFunction),
(Flake8Async, "221") => (RuleGroup::Stable, rules::flake8_async::rules::RunProcessInAsyncFunction),
(Flake8Async, "222") => (RuleGroup::Stable, rules::flake8_async::rules::WaitForProcessInAsyncFunction),

View file

@ -23,6 +23,7 @@ mod tests {
#[test_case(Rule::AsyncZeroSleep, Path::new("ASYNC115.py"))]
#[test_case(Rule::LongSleepNotForever, Path::new("ASYNC116.py"))]
#[test_case(Rule::BlockingHttpCallInAsyncFunction, Path::new("ASYNC210.py"))]
#[test_case(Rule::BlockingHttpCallHttpxInAsyncFunction, Path::new("ASYNC212.py"))]
#[test_case(Rule::CreateSubprocessInAsyncFunction, Path::new("ASYNC22x.py"))]
#[test_case(Rule::RunProcessInAsyncFunction, Path::new("ASYNC22x.py"))]
#[test_case(Rule::WaitForProcessInAsyncFunction, Path::new("ASYNC22x.py"))]

View file

@ -0,0 +1,145 @@
use ruff_python_ast::{self as ast, Expr, ExprCall};
use ruff_macros::{ViolationMetadata, derive_message_formats};
use ruff_python_semantic::analyze::typing::{TypeChecker, check_type, traverse_union_and_optional};
use ruff_text_size::Ranged;
use crate::Violation;
use crate::checkers::ast::Checker;
/// ## What it does
/// Checks that async functions do not use blocking httpx clients.
///
/// ## Why is this bad?
/// Blocking an async function via a blocking HTTP call will block the entire
/// event loop, preventing it from executing other tasks while waiting for the
/// HTTP response, negating the benefits of asynchronous programming.
///
/// Instead of using the blocking `httpx` client, use the asynchronous client.
///
/// ## Example
/// ```python
/// import httpx
///
///
/// async def fetch():
/// client = httpx.Client()
/// response = client.get(...)
/// ```
///
/// Use instead:
/// ```python
/// import httpx
///
///
/// async def fetch():
/// async with httpx.AsyncClient() as client:
/// response = await client.get(...)
/// ```
#[derive(ViolationMetadata)]
pub(crate) struct BlockingHttpCallHttpxInAsyncFunction {
name: String,
call: String,
}
impl Violation for BlockingHttpCallHttpxInAsyncFunction {
#[derive_message_formats]
fn message(&self) -> String {
format!(
"Blocking httpx method {name}.{call}() in async context, use httpx.AsyncClient",
name = self.name,
call = self.call,
)
}
}
struct HttpxClientChecker;
impl TypeChecker for HttpxClientChecker {
fn match_annotation(
annotation: &ruff_python_ast::Expr,
semantic: &ruff_python_semantic::SemanticModel,
) -> bool {
// match base annotation directly
if semantic
.resolve_qualified_name(annotation)
.is_some_and(|qualified_name| matches!(qualified_name.segments(), ["httpx", "Client"]))
{
return true;
}
// otherwise traverse any union or optional annotation
let mut found = false;
traverse_union_and_optional(
&mut |inner_expr, _| {
if semantic
.resolve_qualified_name(inner_expr)
.is_some_and(|qualified_name| {
matches!(qualified_name.segments(), ["httpx", "Client"])
})
{
found = true;
}
},
semantic,
annotation,
);
found
}
fn match_initializer(
initializer: &ruff_python_ast::Expr,
semantic: &ruff_python_semantic::SemanticModel,
) -> bool {
let Expr::Call(ExprCall { func, .. }) = initializer else {
return false;
};
semantic
.resolve_qualified_name(func)
.is_some_and(|qualified_name| matches!(qualified_name.segments(), ["httpx", "Client"]))
}
}
/// ASYNC212
pub(crate) fn blocking_http_call_httpx(checker: &Checker, call: &ExprCall) {
let semantic = checker.semantic();
if !semantic.in_async_context() {
return;
}
let Some(ast::ExprAttribute { value, attr, .. }) = call.func.as_attribute_expr() else {
return;
};
let Some(name) = value.as_name_expr() else {
return;
};
let Some(binding) = semantic.only_binding(name).map(|id| semantic.binding(id)) else {
return;
};
if check_type::<HttpxClientChecker>(binding, semantic) {
if matches!(
attr.id.as_str(),
"close"
| "delete"
| "get"
| "head"
| "options"
| "patch"
| "post"
| "put"
| "request"
| "send"
| "stream"
) {
checker.report_diagnostic(
BlockingHttpCallHttpxInAsyncFunction {
name: name.id.to_string(),
call: attr.id.to_string(),
},
call.func.range(),
);
}
}
}

View file

@ -2,6 +2,7 @@ pub(crate) use async_busy_wait::*;
pub(crate) use async_function_with_timeout::*;
pub(crate) use async_zero_sleep::*;
pub(crate) use blocking_http_call::*;
pub(crate) use blocking_http_call_httpx::*;
pub(crate) use blocking_open_call::*;
pub(crate) use blocking_process_invocation::*;
pub(crate) use blocking_sleep::*;
@ -13,6 +14,7 @@ mod async_busy_wait;
mod async_function_with_timeout;
mod async_zero_sleep;
mod blocking_http_call;
mod blocking_http_call_httpx;
mod blocking_open_call;
mod blocking_process_invocation;
mod blocking_sleep;

View file

@ -0,0 +1,168 @@
---
source: crates/ruff_linter/src/rules/flake8_async/mod.rs
---
ASYNC212 Blocking httpx method client.close() in async context, use httpx.AsyncClient
--> ASYNC212.py:27:5
|
25 | async def foo():
26 | client = httpx.Client()
27 | client.close() # ASYNC212
| ^^^^^^^^^^^^
28 | client.delete() # ASYNC212
29 | client.get() # ASYNC212
|
ASYNC212 Blocking httpx method client.delete() in async context, use httpx.AsyncClient
--> ASYNC212.py:28:5
|
26 | client = httpx.Client()
27 | client.close() # ASYNC212
28 | client.delete() # ASYNC212
| ^^^^^^^^^^^^^
29 | client.get() # ASYNC212
30 | client.head() # ASYNC212
|
ASYNC212 Blocking httpx method client.get() in async context, use httpx.AsyncClient
--> ASYNC212.py:29:5
|
27 | client.close() # ASYNC212
28 | client.delete() # ASYNC212
29 | client.get() # ASYNC212
| ^^^^^^^^^^
30 | client.head() # ASYNC212
31 | client.options() # ASYNC212
|
ASYNC212 Blocking httpx method client.head() in async context, use httpx.AsyncClient
--> ASYNC212.py:30:5
|
28 | client.delete() # ASYNC212
29 | client.get() # ASYNC212
30 | client.head() # ASYNC212
| ^^^^^^^^^^^
31 | client.options() # ASYNC212
32 | client.patch() # ASYNC212
|
ASYNC212 Blocking httpx method client.options() in async context, use httpx.AsyncClient
--> ASYNC212.py:31:5
|
29 | client.get() # ASYNC212
30 | client.head() # ASYNC212
31 | client.options() # ASYNC212
| ^^^^^^^^^^^^^^
32 | client.patch() # ASYNC212
33 | client.post() # ASYNC212
|
ASYNC212 Blocking httpx method client.patch() in async context, use httpx.AsyncClient
--> ASYNC212.py:32:5
|
30 | client.head() # ASYNC212
31 | client.options() # ASYNC212
32 | client.patch() # ASYNC212
| ^^^^^^^^^^^^
33 | client.post() # ASYNC212
34 | client.put() # ASYNC212
|
ASYNC212 Blocking httpx method client.post() in async context, use httpx.AsyncClient
--> ASYNC212.py:33:5
|
31 | client.options() # ASYNC212
32 | client.patch() # ASYNC212
33 | client.post() # ASYNC212
| ^^^^^^^^^^^
34 | client.put() # ASYNC212
35 | client.request() # ASYNC212
|
ASYNC212 Blocking httpx method client.put() in async context, use httpx.AsyncClient
--> ASYNC212.py:34:5
|
32 | client.patch() # ASYNC212
33 | client.post() # ASYNC212
34 | client.put() # ASYNC212
| ^^^^^^^^^^
35 | client.request() # ASYNC212
36 | client.send() # ASYNC212
|
ASYNC212 Blocking httpx method client.request() in async context, use httpx.AsyncClient
--> ASYNC212.py:35:5
|
33 | client.post() # ASYNC212
34 | client.put() # ASYNC212
35 | client.request() # ASYNC212
| ^^^^^^^^^^^^^^
36 | client.send() # ASYNC212
37 | client.stream() # ASYNC212
|
ASYNC212 Blocking httpx method client.send() in async context, use httpx.AsyncClient
--> ASYNC212.py:36:5
|
34 | client.put() # ASYNC212
35 | client.request() # ASYNC212
36 | client.send() # ASYNC212
| ^^^^^^^^^^^
37 | client.stream() # ASYNC212
|
ASYNC212 Blocking httpx method client.stream() in async context, use httpx.AsyncClient
--> ASYNC212.py:37:5
|
35 | client.request() # ASYNC212
36 | client.send() # ASYNC212
37 | client.stream() # ASYNC212
| ^^^^^^^^^^^^^
38 |
39 | client.anything() # Ok
|
ASYNC212 Blocking httpx method client.request() in async context, use httpx.AsyncClient
--> ASYNC212.py:45:5
|
44 | async def foo(client: httpx.Client):
45 | client.request() # ASYNC212
| ^^^^^^^^^^^^^^
46 | client.anything() # Ok
|
ASYNC212 Blocking httpx method client.request() in async context, use httpx.AsyncClient
--> ASYNC212.py:50:5
|
49 | async def foo(client: httpx.Client | None):
50 | client.request() # ASYNC212
| ^^^^^^^^^^^^^^
51 | client.anything() # Ok
|
ASYNC212 Blocking httpx method client.request() in async context, use httpx.AsyncClient
--> ASYNC212.py:55:5
|
54 | async def foo(client: Optional[httpx.Client]):
55 | client.request() # ASYNC212
| ^^^^^^^^^^^^^^
56 | client.anything() # Ok
|
ASYNC212 Blocking httpx method client.request() in async context, use httpx.AsyncClient
--> ASYNC212.py:61:5
|
59 | async def foo():
60 | client: httpx.Client = ...
61 | client.request() # ASYNC212
| ^^^^^^^^^^^^^^
62 | client.anything() # Ok
|
ASYNC212 Blocking httpx method global_client.request() in async context, use httpx.AsyncClient
--> ASYNC212.py:69:5
|
68 | async def foo():
69 | global_client.request() # ASYNC212
| ^^^^^^^^^^^^^^^^^^^^^
70 | global_client.anything() # Ok
|

1
ruff.schema.json generated
View file

@ -3013,6 +3013,7 @@
"ASYNC2",
"ASYNC21",
"ASYNC210",
"ASYNC212",
"ASYNC22",
"ASYNC220",
"ASYNC221",