You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
ansible/test/lib/ansible_test/_internal/http.py

122 lines
3.8 KiB
Python

"""A simple HTTP client."""
from __future__ import annotations
import http.client
import json
import time
import typing as t
import urllib.error
import urllib.request
from .util import (
ApplicationError,
display,
)
from .util_common import (
CommonConfig,
)
class HttpClient:
"""Make HTTP requests."""
def __init__(self, args: CommonConfig, always: bool = False) -> None:
self.args = args
self.always = always
def get(self, url: str) -> HttpResponse:
"""Perform an HTTP GET and return the response."""
return self.request('GET', url)
def delete(self, url: str) -> HttpResponse:
"""Perform an HTTP DELETE and return the response."""
return self.request('DELETE', url)
def put(self, url: str, data: t.Optional[str] = None, headers: t.Optional[dict[str, str]] = None) -> HttpResponse:
"""Perform an HTTP PUT and return the response."""
return self.request('PUT', url, data, headers)
def request(self, method: str, url: str, data: t.Optional[str] = None, headers: t.Optional[dict[str, str]] = None) -> HttpResponse:
"""Perform an HTTP request and return the response."""
if headers is None:
headers = {}
data_bytes = data.encode() if data else None
request = urllib.request.Request(method=method, url=url, data=data_bytes, headers=headers)
response: http.client.HTTPResponse
display.info(f'HTTP {method} {url}', verbosity=2)
attempts = 0
max_attempts = 3
sleep_seconds = 3
status_code = 200
reason = 'OK'
body_bytes = b''
while True:
attempts += 1
start = time.monotonic()
if self.args.explain and not self.always:
break
try:
try:
with urllib.request.urlopen(request) as response:
status_code = response.status
reason = response.reason
body_bytes = response.read()
except urllib.error.HTTPError as ex:
status_code = ex.status
reason = ex.reason
body_bytes = ex.read()
except Exception as ex: # pylint: disable=broad-exception-caught
if attempts >= max_attempts:
raise
# all currently implemented methods are idempotent, so retries are unconditionally supported
duration = time.monotonic() - start
display.warning(f'{type(ex).__module__}.{type(ex).__name__}: {ex} [{duration:.2f} seconds]')
time.sleep(sleep_seconds)
continue
break
duration = time.monotonic() - start
display.info(f'HTTP {method} {url} -> HTTP {status_code} ({reason}) [{len(body_bytes)} bytes, {duration:.2f} seconds]', verbosity=3)
body = body_bytes.decode()
return HttpResponse(method, url, status_code, body)
class HttpResponse:
"""HTTP response."""
def __init__(self, method: str, url: str, status_code: int, response: str) -> None:
self.method = method
self.url = url
self.status_code = status_code
self.response = response
def json(self) -> t.Any:
"""Return the response parsed as JSON, raising an exception if parsing fails."""
try:
return json.loads(self.response)
except ValueError:
raise HttpError(self.status_code, 'Cannot parse response to %s %s as JSON:\n%s' % (self.method, self.url, self.response)) from None
class HttpError(ApplicationError):
"""HTTP response as an error."""
def __init__(self, status: int, message: str) -> None:
super().__init__('%s: %s' % (status, message))
self.status = status