Repository navigation
Add HttpHookAsync for deferrable implementation #29038
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
8e12918
90cd247
d28b6d1
a58ca1a
b550adb
921dea7
42b130d
6e13adc
624e189
366f451
44a8ac7
1ab4218
0009876
dace179
272ef8f
0fba14b
0a1e683
36e1e7a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -17,16 +17,23 @@ | |
| # under the License. | ||
| from __future__ import annotations | ||
|
|
||
| from typing import Any, Callable | ||
| import asyncio | ||
| from typing import TYPE_CHECKING, Any, Callable | ||
|
|
||
| import aiohttp | ||
| import requests | ||
| import tenacity | ||
| from aiohttp import ClientResponseError | ||
| from asgiref.sync import sync_to_async | ||
| from requests.auth import HTTPBasicAuth | ||
| from requests_toolbelt.adapters.socket_options import TCPKeepAliveAdapter | ||
|
|
||
| from airflow.exceptions import AirflowException | ||
| from airflow.hooks.base import BaseHook | ||
|
|
||
| if TYPE_CHECKING: | ||
| from aiohttp.client_reqrep import ClientResponse | ||
|
|
||
|
|
||
| class HttpHook(BaseHook): | ||
| """ | ||
|
|
@@ -246,3 +253,157 @@ def test_connection(self): | |
| return True, "Connection successfully tested" | ||
| except Exception as e: | ||
| return False, str(e) | ||
|
|
||
|
|
||
| class HttpAsyncHook(BaseHook): | ||
| """ | ||
| Interact with HTTP servers using Python Async. | ||
|
|
||
| :param method: the API method to be called | ||
| :param http_conn_id: http connection id that has the base | ||
| API url i.e https://www.google.com/ and optional authentication credentials. Default | ||
| headers can also be specified in the Extra field in json format. | ||
| :param auth_type: The auth type for the service | ||
| """ | ||
|
|
||
| conn_name_attr = "http_conn_id" | ||
| default_conn_name = "http_default" | ||
| conn_type = "http" | ||
| hook_name = "HTTP" | ||
|
|
||
| def __init__( | ||
| self, | ||
| method: str = "POST", | ||
| http_conn_id: str = default_conn_name, | ||
| auth_type: Any = aiohttp.BasicAuth, | ||
| retry_limit: int = 3, | ||
| retry_delay: float = 1.0, | ||
|
Comment on lines
+279
to
+280
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe better to have exponential backoff retry?
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could you elaborate why exponential backoff retry would be better suited here?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For unknown pattern of usage exponential backoff usual is a better:
|
||
| ) -> None: | ||
| self.http_conn_id = http_conn_id | ||
| self.method = method.upper() | ||
| self.base_url: str = "" | ||
| self._retry_obj: Callable[..., Any] | ||
| self.auth_type: Any = auth_type | ||
| if retry_limit < 1: | ||
| raise ValueError("Retry limit must be greater than equal to 1") | ||
| self.retry_limit = retry_limit | ||
| self.retry_delay = retry_delay | ||
|
|
||
| async def run( | ||
| self, | ||
| endpoint: str | None = None, | ||
| data: dict[str, Any] | str | None = None, | ||
| headers: dict[str, Any] | None = None, | ||
| extra_options: dict[str, Any] | None = None, | ||
| ) -> "ClientResponse": | ||
| r""" | ||
| Performs an asynchronous HTTP request call | ||
|
|
||
| :param endpoint: the endpoint to be called i.e. resource/v1/query? | ||
| :param data: payload to be uploaded or request parameters | ||
| :param headers: additional headers to be passed through as a dictionary | ||
| :param extra_options: Additional kwargs to pass when creating a request. | ||
| For example, ``run(json=obj)`` is passed as ``aiohttp.ClientSession().get(json=obj)`` | ||
| """ | ||
| extra_options = extra_options or {} | ||
|
|
||
| # headers may be passed through directly or in the "extra" field in the connection | ||
| # definition | ||
| _headers = {} | ||
| auth = None | ||
|
|
||
| if self.http_conn_id: | ||
|
Comment on lines
+314
to
+315
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is it intend to get connection for every I see that the same behaviour exists in Requests-based hook but I don't think this is a good approach, most of the time we would spend for get connection.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. And also don't know what happen when multiple different Trigger's will try to call
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @Taragolis when we call
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Well, I found a time for check this magic. As I could see It will run all functions/methods decorated by That is some simple sample import asyncio
import datetime
import time
from asgiref.sync import sync_to_async
@sync_to_async
def task(value):
print(f"{datetime.datetime.utcnow().isoformat()}, sync_to_async start {value!r}")
time.sleep(3)
print(f"{datetime.datetime.utcnow().isoformat()}, sync_to_async end {value!r}")
async def async_task(value):
print(f"{datetime.datetime.utcnow().isoformat()}, async_task start {value!r}")
await asyncio.sleep(3)
print(f"{datetime.datetime.utcnow().isoformat()}, async_task end {value!r}")
async def main():
await asyncio.gather(
asyncio.create_task(task(1)),
asyncio.create_task(task(2)),
asyncio.create_task(async_task(3)),
asyncio.create_task(async_task(4)),
asyncio.create_task(task(5)),
asyncio.create_task(async_task(6)),
)
if __name__ == "__main__":
asyncio.run(main())
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @Taragolis we have already used @andrewgodwin @kaxil WDYT?
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. More details here: https://docs.djangoproject.com/en/4.1/topics/async/#sync-to-async
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The main point that Code which run inside So, unfortunetly ,"we have already used sync_to_async in various airflow provider's code base currently as well like" not make things better rather than create some kind of time bomb. I don't mean that you doing a bad job and all this |
||
| conn = await sync_to_async(self.get_connection)(self.http_conn_id) | ||
|
sunank200 marked this conversation as resolved.
Resolved
|
||
|
|
||
| if conn.host and "://" in conn.host: | ||
| self.base_url = conn.host | ||
| else: | ||
| # schema defaults to HTTP | ||
| schema = conn.schema if conn.schema else "http" | ||
| host = conn.host if conn.host else "" | ||
| self.base_url = schema + "://" + host | ||
|
|
||
| if conn.port: | ||
| self.base_url = self.base_url + ":" + str(conn.port) | ||
| if conn.login: | ||
| auth = self.auth_type(conn.login, conn.password) | ||
| if conn.extra: | ||
| try: | ||
| _headers.update(conn.extra_dejson) | ||
| except TypeError: | ||
| self.log.warning("Connection to %s has invalid extra field.", conn.host) | ||
| if headers: | ||
| _headers.update(headers) | ||
|
|
||
| if self.base_url and not self.base_url.endswith("/") and endpoint and not endpoint.startswith("/"): | ||
| url = self.base_url + "/" + endpoint | ||
| else: | ||
| url = (self.base_url or "") + (endpoint or "") | ||
|
|
||
| async with aiohttp.ClientSession() as session: | ||
| if self.method == "GET": | ||
| request_func = session.get | ||
| elif self.method == "POST": | ||
| request_func = session.post | ||
| elif self.method == "PATCH": | ||
| request_func = session.patch | ||
| elif self.method == "HEAD": | ||
| request_func = session.head | ||
| elif self.method == "PUT": | ||
| request_func = session.put | ||
| elif self.method == "DELETE": | ||
| request_func = session.delete | ||
| elif self.method == "OPTIONS": | ||
| request_func = session.options | ||
| else: | ||
| raise AirflowException(f"Unexpected HTTP Method: {self.method}") | ||
|
sunank200 marked this conversation as resolved.
Resolved
|
||
|
|
||
| attempt_num = 1 | ||
| while True: | ||
| response = await request_func( | ||
| url, | ||
| json=data if self.method in ("POST", "PATCH") else None, | ||
| params=data if self.method == "GET" else None, | ||
| headers=headers, | ||
| auth=auth, | ||
| **extra_options, | ||
| ) | ||
| try: | ||
| response.raise_for_status() | ||
| return response | ||
| except ClientResponseError as e: | ||
| self.log.warning( | ||
| "[Try %d of %d] Request to %s failed.", | ||
| attempt_num, | ||
| self.retry_limit, | ||
| url, | ||
| ) | ||
| if not self._retryable_error_async(e) or attempt_num == self.retry_limit: | ||
| self.log.exception("HTTP error with status: %s", e.status) | ||
| # In this case, the user probably made a mistake. | ||
| # Don't retry. | ||
| raise AirflowException(f"{e.status}:{e.message}") | ||
|
|
||
| attempt_num += 1 | ||
| await asyncio.sleep(self.retry_delay) | ||
|
|
||
| def _retryable_error_async(self, exception: ClientResponseError) -> bool: | ||
| """ | ||
| Determines whether or not an exception that was thrown might be successful | ||
| on a subsequent attempt. | ||
|
|
||
| It considers the following to be retryable: | ||
| - requests_exceptions.ConnectionError | ||
| - requests_exceptions.Timeout | ||
|
sunank200 marked this conversation as resolved.
Resolved
|
||
| - anything with a status code >= 500 | ||
|
|
||
| Most retryable errors are covered by status code >= 500. | ||
| """ | ||
| if exception.status == 429: | ||
| # don't retry for too Many Requests | ||
| return False | ||
| if exception.status == 413: | ||
| # don't retry for payload Too Large | ||
| return False | ||
|
|
||
| return exception.status >= 500 | ||
|
sunank200 marked this conversation as resolved.
Resolved
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -415,6 +415,8 @@ | |
| }, | ||
| "http": { | ||
| "deps": [ | ||
| "aiohttp", | ||
| "asgiref", | ||
| "requests>=2.26.0", | ||
| "requests_toolbelt" | ||
| ], | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.