From af259faed57f2428da4fa09aab03dbb96b0f160e Mon Sep 17 00:00:00 2001 From: Amethyst Reese Date: Wed, 27 Aug 2025 15:19:01 -0700 Subject: [PATCH] [`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`. --- .../test/fixtures/flake8_async/ASYNC212.py | 75 ++++++++ .../src/checkers/ast/analyze/expression.rs | 3 + crates/ruff_linter/src/codes.rs | 1 + .../ruff_linter/src/rules/flake8_async/mod.rs | 1 + .../rules/blocking_http_call_httpx.rs | 145 +++++++++++++++ .../src/rules/flake8_async/rules/mod.rs | 2 + ...e8_async__tests__ASYNC212_ASYNC212.py.snap | 168 ++++++++++++++++++ ruff.schema.json | 1 + 8 files changed, 396 insertions(+) create mode 100644 crates/ruff_linter/resources/test/fixtures/flake8_async/ASYNC212.py create mode 100644 crates/ruff_linter/src/rules/flake8_async/rules/blocking_http_call_httpx.rs create mode 100644 crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__ASYNC212_ASYNC212.py.snap diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_async/ASYNC212.py b/crates/ruff_linter/resources/test/fixtures/flake8_async/ASYNC212.py new file mode 100644 index 0000000000..d6a798debc --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/flake8_async/ASYNC212.py @@ -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 diff --git a/crates/ruff_linter/src/checkers/ast/analyze/expression.rs b/crates/ruff_linter/src/checkers/ast/analyze/expression.rs index 9a6c4b52c9..c2f1afe584 100644 --- a/crates/ruff_linter/src/checkers/ast/analyze/expression.rs +++ b/crates/ruff_linter/src/checkers/ast/analyze/expression.rs @@ -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); } diff --git a/crates/ruff_linter/src/codes.rs b/crates/ruff_linter/src/codes.rs index bb29e285d7..ebf0d7babb 100644 --- a/crates/ruff_linter/src/codes.rs +++ b/crates/ruff_linter/src/codes.rs @@ -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), diff --git a/crates/ruff_linter/src/rules/flake8_async/mod.rs b/crates/ruff_linter/src/rules/flake8_async/mod.rs index b7346e2429..7389abf603 100644 --- a/crates/ruff_linter/src/rules/flake8_async/mod.rs +++ b/crates/ruff_linter/src/rules/flake8_async/mod.rs @@ -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"))] diff --git a/crates/ruff_linter/src/rules/flake8_async/rules/blocking_http_call_httpx.rs b/crates/ruff_linter/src/rules/flake8_async/rules/blocking_http_call_httpx.rs new file mode 100644 index 0000000000..e46905774e --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_async/rules/blocking_http_call_httpx.rs @@ -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::(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(), + ); + } + } +} diff --git a/crates/ruff_linter/src/rules/flake8_async/rules/mod.rs b/crates/ruff_linter/src/rules/flake8_async/rules/mod.rs index 1b115a3c8b..32b2fa8e36 100644 --- a/crates/ruff_linter/src/rules/flake8_async/rules/mod.rs +++ b/crates/ruff_linter/src/rules/flake8_async/rules/mod.rs @@ -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; diff --git a/crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__ASYNC212_ASYNC212.py.snap b/crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__ASYNC212_ASYNC212.py.snap new file mode 100644 index 0000000000..9e641816a3 --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_async/snapshots/ruff_linter__rules__flake8_async__tests__ASYNC212_ASYNC212.py.snap @@ -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 + | diff --git a/ruff.schema.json b/ruff.schema.json index 0866fc0426..1b4d6ac27f 100644 --- a/ruff.schema.json +++ b/ruff.schema.json @@ -3013,6 +3013,7 @@ "ASYNC2", "ASYNC21", "ASYNC210", + "ASYNC212", "ASYNC22", "ASYNC220", "ASYNC221",