From beac9006030fb0a10c3e88ef238e82238ecdc4d3 Mon Sep 17 00:00:00 2001 From: Petr Date: Sat, 28 Feb 2026 01:13:08 +0100 Subject: [PATCH] Phase 2: Extract shared constants and HTTP base class - NEW: constants.py with all shared constants (retry params, timeouts, env var names, default values, job limits, token description) - NEW: http_base.py with BaseHttpClient providing shared _do_request() and _raise_api_error() with retry logic and message sanitization - Refactor KeboolaClient and ManageClient to inherit from BaseHttpClient - Remove ~220 lines of duplicated HTTP retry/error code - Replace all hardcoded constants across 8 files with imports from constants.py (job limits, stack URL, token description, env vars) - Add 17 new BaseHttpClient tests (retry, error mapping, sanitization) - All 466 existing + new tests pass --- src/keboola_agent_cli/client.py | 154 +------ src/keboola_agent_cli/commands/job.py | 9 +- src/keboola_agent_cli/commands/org.py | 9 +- src/keboola_agent_cli/commands/project.py | 9 +- src/keboola_agent_cli/constants.py | 37 ++ src/keboola_agent_cli/http_base.py | 200 ++++++++ src/keboola_agent_cli/manage_client.py | 142 +----- src/keboola_agent_cli/services/base.py | 4 +- src/keboola_agent_cli/services/job_service.py | 5 +- src/keboola_agent_cli/services/org_service.py | 3 +- tests/test_client.py | 75 +-- tests/test_http_base.py | 436 ++++++++++++++++++ 12 files changed, 769 insertions(+), 314 deletions(-) create mode 100644 src/keboola_agent_cli/constants.py create mode 100644 src/keboola_agent_cli/http_base.py create mode 100644 tests/test_http_base.py diff --git a/src/keboola_agent_cli/client.py b/src/keboola_agent_cli/client.py index 2bd8fe4b..fb00d7ca 100644 --- a/src/keboola_agent_cli/client.py +++ b/src/keboola_agent_cli/client.py @@ -3,43 +3,42 @@ This is the only module that communicates with the Keboola Storage API and the Keboola Queue API. All HTTP details, endpoint URLs, and error mapping are encapsulated here. + +Inherits shared retry/error logic from BaseHttpClient. """ -import time from typing import Any from urllib.parse import quote, urlparse, urlunparse import httpx from . import __version__ -from .errors import KeboolaApiError, mask_token +from .constants import DEFAULT_JOB_LIMIT, DEFAULT_TIMEOUT +from .http_base import BaseHttpClient from .models import TokenVerifyResponse -RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504} -MAX_RETRIES = 3 -BACKOFF_BASE = 1.0 # seconds; delays: 1s, 2s, 4s - -class KeboolaClient: +class KeboolaClient(BaseHttpClient): """HTTP client for the Keboola Storage API and Queue API. Provides methods to interact with Keboola endpoints with built-in retry logic (exponential backoff for 429/5xx), timeouts, and automatic token masking in error messages. + + Inherits _do_request() and _raise_api_error() from BaseHttpClient. """ def __init__(self, stack_url: str, token: str) -> None: self._stack_url = stack_url.rstrip("/") - self._token = token - self._masked_token = mask_token(token) - self._headers = { + headers = { "X-StorageApi-Token": token, "User-Agent": f"keboola-agent-cli/{__version__}", } - self._client = httpx.Client( + super().__init__( base_url=self._stack_url, - timeout=httpx.Timeout(connect=5.0, read=30.0, write=10.0, pool=5.0), - headers=self._headers, + token=token, + headers=headers, + timeout=DEFAULT_TIMEOUT, ) self._queue_client: httpx.Client | None = None @@ -57,7 +56,7 @@ def _queue_base_url(self) -> str: def close(self) -> None: """Close the underlying HTTP clients.""" - self._client.close() + super().close() if self._queue_client is not None: self._queue_client.close() @@ -67,132 +66,23 @@ def __enter__(self) -> "KeboolaClient": def __exit__(self, *args: Any) -> None: self.close() - def _do_request( - self, client: httpx.Client, base_url: str, method: str, path: str, **kwargs: Any - ) -> httpx.Response: - """Execute an HTTP request with retry and exponential backoff. - - Shared retry logic for both Storage and Queue API clients. - - Retries on status codes 429, 500, 502, 503, 504 up to MAX_RETRIES times - with exponential backoff (1s, 2s, 4s). - - Raises: - KeboolaApiError: On HTTP errors (with masked token) or after retries exhausted. - """ - last_response: httpx.Response | None = None - - for attempt in range(MAX_RETRIES): - try: - response = client.request(method, path, **kwargs) - - if response.status_code < 400: - return response - - if response.status_code in RETRYABLE_STATUS_CODES and attempt < MAX_RETRIES - 1: - delay = BACKOFF_BASE * (2**attempt) - time.sleep(delay) - last_response = response - continue - - self._raise_api_error(response, base_url) - - except httpx.TimeoutException as exc: - if attempt < MAX_RETRIES - 1: - delay = BACKOFF_BASE * (2**attempt) - time.sleep(delay) - continue - raise KeboolaApiError( - message=f"Request timed out connecting to {base_url} (token: {self._masked_token})", - status_code=0, - error_code="TIMEOUT", - retryable=True, - ) from exc - - except httpx.ConnectError as exc: - if attempt < MAX_RETRIES - 1: - delay = BACKOFF_BASE * (2**attempt) - time.sleep(delay) - continue - raise KeboolaApiError( - message=f"Cannot connect to {base_url} (token: {self._masked_token})", - status_code=0, - error_code="CONNECTION_ERROR", - retryable=True, - ) from exc - - if last_response is not None: - self._raise_api_error(last_response, base_url) - - raise KeboolaApiError( - message=f"Request failed after {MAX_RETRIES} retries to {base_url} (token: {self._masked_token})", - status_code=0, - error_code="RETRY_EXHAUSTED", - retryable=True, - ) - def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response: """Execute a Storage API request with retry.""" - return self._do_request(self._client, self._stack_url, method, path, **kwargs) + return self._do_request(method, path, **kwargs) def _queue_request(self, method: str, path: str, **kwargs: Any) -> httpx.Response: """Execute a Queue API request with retry. Lazily creates the queue client.""" if self._queue_client is None: self._queue_client = httpx.Client( base_url=self._queue_base_url, - timeout=httpx.Timeout(connect=5.0, read=30.0, write=10.0, pool=5.0), - headers=self._headers, + timeout=DEFAULT_TIMEOUT, + headers=self._client._headers.copy(), ) return self._do_request( - self._queue_client, self._queue_base_url, method, path, **kwargs - ) - - def _raise_api_error(self, response: httpx.Response, base_url: str | None = None) -> None: - """Convert an HTTP error response into a KeboolaApiError.""" - status = response.status_code - url_label = base_url or self._stack_url - - try: - body = response.json() - api_message = body.get("error", body.get("message", response.text)) - except Exception: - api_message = response.text - - # Truncate to prevent Rich markup injection and excessive output - max_api_error_length = 500 - if isinstance(api_message, str) and len(api_message) > max_api_error_length: - api_message = api_message[:max_api_error_length] + "..." - - if status == 401: - raise KeboolaApiError( - message=f"Invalid or expired token (token: {self._masked_token}): {api_message}", - status_code=status, - error_code="INVALID_TOKEN", - retryable=False, - ) - - if status == 403: - raise KeboolaApiError( - message=f"Access denied (token: {self._masked_token}): {api_message}", - status_code=status, - error_code="ACCESS_DENIED", - retryable=False, - ) - - if status == 404: - raise KeboolaApiError( - message=f"Resource not found: {api_message}", - status_code=status, - error_code="NOT_FOUND", - retryable=False, - ) - - retryable = status in RETRYABLE_STATUS_CODES - raise KeboolaApiError( - message=f"API error {status} from {url_label} (token: {self._masked_token}): {api_message}", - status_code=status, - error_code="API_ERROR", - retryable=retryable, + method, path, + client=self._queue_client, + base_url=self._queue_base_url, + **kwargs, ) def verify_token(self) -> TokenVerifyResponse: @@ -269,7 +159,7 @@ def list_jobs( component_id: str | None = None, config_id: str | None = None, status: str | None = None, - limit: int = 50, + limit: int = DEFAULT_JOB_LIMIT, offset: int = 0, ) -> list[dict[str, Any]]: """List jobs from the Queue API. @@ -278,7 +168,7 @@ def list_jobs( component_id: Optional filter by component ID. config_id: Optional filter by config ID (requires component_id). status: Optional filter by job status. - limit: Max number of jobs to return (1-500, default 50). + limit: Max number of jobs to return (1-500). offset: Offset for pagination. Returns: diff --git a/src/keboola_agent_cli/commands/job.py b/src/keboola_agent_cli/commands/job.py index 54f2c1ad..5cd3a71e 100644 --- a/src/keboola_agent_cli/commands/job.py +++ b/src/keboola_agent_cli/commands/job.py @@ -6,6 +6,7 @@ import typer +from ..constants import DEFAULT_JOB_LIMIT, MAX_JOB_LIMIT from ..errors import ConfigError, KeboolaApiError from ..output import OutputFormatter, format_job_detail, format_jobs_table from ..services.job_service import JobService @@ -49,9 +50,9 @@ def job_list( help="Filter by job status: processing, terminated, cancelled, success, error", ), limit: int = typer.Option( - 50, + DEFAULT_JOB_LIMIT, "--limit", - help="Maximum number of jobs to return per project (1-500)", + help=f"Maximum number of jobs to return per project (1-{MAX_JOB_LIMIT})", ), ) -> None: """List jobs from connected projects.""" @@ -67,9 +68,9 @@ def job_list( raise typer.Exit(code=2) # Validate limit range - if limit < 1 or limit > 500: + if limit < 1 or limit > MAX_JOB_LIMIT: formatter.error( - message=f"Invalid limit {limit}. Must be between 1 and 500.", + message=f"Invalid limit {limit}. Must be between 1 and {MAX_JOB_LIMIT}.", error_code="INVALID_ARGUMENT", ) raise typer.Exit(code=2) diff --git a/src/keboola_agent_cli/commands/org.py b/src/keboola_agent_cli/commands/org.py index 809c1032..bad61a1c 100644 --- a/src/keboola_agent_cli/commands/org.py +++ b/src/keboola_agent_cli/commands/org.py @@ -11,6 +11,7 @@ from rich.console import Console from rich.table import Table +from ..constants import DEFAULT_TOKEN_DESCRIPTION, ENV_KBC_MANAGE_API_TOKEN, ENV_KBC_STORAGE_API_URL from ..errors import KeboolaApiError from ..output import OutputFormatter from ..services.org_service import OrgService @@ -42,7 +43,7 @@ def _resolve_manage_token() -> str: Raises: typer.Exit: If no token can be resolved. """ - env_token = os.environ.get("KBC_MANAGE_API_TOKEN") + env_token = os.environ.get(ENV_KBC_MANAGE_API_TOKEN) if env_token: return env_token @@ -51,7 +52,7 @@ def _resolve_manage_token() -> str: return typer.prompt("Manage API token", hide_input=True) typer.echo( - "Error: No manage token available. Set KBC_MANAGE_API_TOKEN env var " + f"Error: No manage token available. Set {ENV_KBC_MANAGE_API_TOKEN} env var " "or run interactively.", err=True, ) @@ -141,7 +142,7 @@ def org_setup( url: str = typer.Option( ..., "--url", - envvar="KBC_STORAGE_API_URL", + envvar=ENV_KBC_STORAGE_API_URL, help="Keboola stack URL (e.g. https://connection.keboola.com)", ), dry_run: bool = typer.Option( @@ -156,7 +157,7 @@ def org_setup( help="Skip confirmation prompt", ), token_description: str = typer.Option( - "kbagent-cli", + DEFAULT_TOKEN_DESCRIPTION, "--token-description", help="Description prefix for created Storage API tokens", ), diff --git a/src/keboola_agent_cli/commands/project.py b/src/keboola_agent_cli/commands/project.py index 6f1519bb..0737f8aa 100644 --- a/src/keboola_agent_cli/commands/project.py +++ b/src/keboola_agent_cli/commands/project.py @@ -12,6 +12,7 @@ from rich.console import Console from rich.table import Table +from ..constants import DEFAULT_STACK_URL, ENV_KBC_STORAGE_API_URL, ENV_KBC_TOKEN from ..errors import ConfigError, KeboolaApiError from ..output import OutputFormatter from ..services.project_service import ProjectService @@ -101,7 +102,7 @@ def _resolve_token() -> str: Raises: typer.Exit: If no token can be resolved. """ - env_token = os.environ.get("KBC_TOKEN") + env_token = os.environ.get(ENV_KBC_TOKEN) if env_token: return env_token @@ -110,7 +111,7 @@ def _resolve_token() -> str: return typer.prompt("Storage API token", hide_input=True) typer.echo( - "Error: No token available. Set KBC_TOKEN env var " + f"Error: No token available. Set {ENV_KBC_TOKEN} env var " "or run interactively.", err=True, ) @@ -122,9 +123,9 @@ def project_add( ctx: typer.Context, alias: str = typer.Option(..., help="Human-friendly name for this project"), url: str = typer.Option( - "https://connection.keboola.com", + DEFAULT_STACK_URL, help="Keboola stack URL", - envvar="KBC_STORAGE_API_URL", + envvar=ENV_KBC_STORAGE_API_URL, ), ) -> None: """Add a new Keboola project connection. diff --git a/src/keboola_agent_cli/constants.py b/src/keboola_agent_cli/constants.py new file mode 100644 index 00000000..a59d6bf9 --- /dev/null +++ b/src/keboola_agent_cli/constants.py @@ -0,0 +1,37 @@ +"""Shared constants for Keboola Agent CLI. + +All magic numbers, default values, retry parameters, timeout settings, +and environment variable names are centralized here to avoid duplication +and ensure consistency across the codebase. +""" + +import httpx + +# --- HTTP Retry Constants --- +RETRYABLE_STATUS_CODES: set[int] = {429, 500, 502, 503, 504} +MAX_RETRIES: int = 3 +BACKOFF_BASE: float = 1.0 # seconds; delays: 1s, 2s, 4s + +# --- HTTP Timeout --- +DEFAULT_TIMEOUT: httpx.Timeout = httpx.Timeout( + connect=5.0, read=30.0, write=10.0, pool=5.0 +) + +# --- API Error Handling --- +MAX_API_ERROR_LENGTH: int = 500 + +# --- Default Stack URL --- +DEFAULT_STACK_URL: str = "https://connection.keboola.com" + +# --- Token Description --- +DEFAULT_TOKEN_DESCRIPTION: str = "kbagent-cli" + +# --- Job Limits --- +DEFAULT_JOB_LIMIT: int = 50 +MAX_JOB_LIMIT: int = 500 + +# --- Environment Variable Names --- +ENV_MAX_PARALLEL_WORKERS: str = "KBAGENT_MAX_PARALLEL_WORKERS" +ENV_KBC_TOKEN: str = "KBC_TOKEN" +ENV_KBC_STORAGE_API_URL: str = "KBC_STORAGE_API_URL" +ENV_KBC_MANAGE_API_TOKEN: str = "KBC_MANAGE_API_TOKEN" diff --git a/src/keboola_agent_cli/http_base.py b/src/keboola_agent_cli/http_base.py new file mode 100644 index 00000000..a548641c --- /dev/null +++ b/src/keboola_agent_cli/http_base.py @@ -0,0 +1,200 @@ +"""Base HTTP client with shared retry, timeout, and error handling logic. + +Both KeboolaClient (Storage API) and ManageClient (Manage API) inherit +from BaseHttpClient to avoid duplicating the retry loop, error mapping, +and message sanitization code. +""" + +import time +from typing import Any + +import httpx + +from .constants import ( + BACKOFF_BASE, + MAX_API_ERROR_LENGTH, + MAX_RETRIES, + RETRYABLE_STATUS_CODES, +) +from .errors import KeboolaApiError, mask_token + + +class BaseHttpClient: + """Shared HTTP client with retry, timeout, and error handling. + + Provides: + - _do_request(method, path, **kwargs): HTTP request with retry + backoff + - _raise_api_error(response, base_url=None): error mapping with truncation + - Context manager support (close, __enter__, __exit__) + + Subclasses must call super().__init__() with base_url, token, headers, + and optional timeout. + """ + + def __init__( + self, + base_url: str, + token: str, + headers: dict[str, str], + timeout: httpx.Timeout | None = None, + ) -> None: + from .constants import DEFAULT_TIMEOUT + + self._base_url = base_url.rstrip("/") + self._token = token + self._masked_token = mask_token(token) + self._client = httpx.Client( + base_url=self._base_url, + timeout=timeout or DEFAULT_TIMEOUT, + headers=headers, + ) + + def close(self) -> None: + """Close the underlying HTTP client.""" + self._client.close() + + def __enter__(self) -> "BaseHttpClient": + return self + + def __exit__(self, *args: Any) -> None: + self.close() + + def _do_request( + self, + method: str, + path: str, + *, + client: httpx.Client | None = None, + base_url: str | None = None, + **kwargs: Any, + ) -> httpx.Response: + """Execute an HTTP request with retry and exponential backoff. + + Retries on status codes 429, 500, 502, 503, 504 up to MAX_RETRIES times + with exponential backoff (1s, 2s, 4s). + + Args: + method: HTTP method (GET, POST, etc.). + path: URL path relative to base_url. + client: Optional httpx.Client to use (defaults to self._client). + Useful for subclasses that maintain multiple clients (e.g. queue client). + base_url: Optional base URL for error messages (defaults to self._base_url). + **kwargs: Additional arguments passed to httpx.Client.request(). + + Returns: + The HTTP response on success. + + Raises: + KeboolaApiError: On HTTP errors (with masked token) or after retries exhausted. + """ + http_client = client or self._client + url_label = base_url or self._base_url + last_response: httpx.Response | None = None + + for attempt in range(MAX_RETRIES): + try: + response = http_client.request(method, path, **kwargs) + + if response.status_code < 400: + return response + + if response.status_code in RETRYABLE_STATUS_CODES and attempt < MAX_RETRIES - 1: + delay = BACKOFF_BASE * (2**attempt) + time.sleep(delay) + last_response = response + continue + + self._raise_api_error(response, url_label) + + except httpx.TimeoutException as exc: + if attempt < MAX_RETRIES - 1: + delay = BACKOFF_BASE * (2**attempt) + time.sleep(delay) + continue + raise KeboolaApiError( + message=f"Request timed out connecting to {url_label} (token: {self._masked_token})", + status_code=0, + error_code="TIMEOUT", + retryable=True, + ) from exc + + except httpx.ConnectError as exc: + if attempt < MAX_RETRIES - 1: + delay = BACKOFF_BASE * (2**attempt) + time.sleep(delay) + continue + raise KeboolaApiError( + message=f"Cannot connect to {url_label} (token: {self._masked_token})", + status_code=0, + error_code="CONNECTION_ERROR", + retryable=True, + ) from exc + + if last_response is not None: + self._raise_api_error(last_response, url_label) + + raise KeboolaApiError( + message=f"Request failed after {MAX_RETRIES} retries to {url_label} (token: {self._masked_token})", + status_code=0, + error_code="RETRY_EXHAUSTED", + retryable=True, + ) + + def _raise_api_error(self, response: httpx.Response, base_url: str | None = None) -> None: + """Convert an HTTP error response into a KeboolaApiError. + + Parses the response body for error messages, truncates long messages + to MAX_API_ERROR_LENGTH characters, and maps status codes to + appropriate error codes. + + Args: + response: The HTTP error response. + base_url: Optional URL label for error messages. + + Raises: + KeboolaApiError: Always raised with appropriate error code and message. + """ + status = response.status_code + url_label = base_url or self._base_url + + try: + body = response.json() + api_message = body.get("error", body.get("message", response.text)) + except Exception: + api_message = response.text + + # Truncate to prevent Rich markup injection and excessive output + if isinstance(api_message, str) and len(api_message) > MAX_API_ERROR_LENGTH: + api_message = api_message[:MAX_API_ERROR_LENGTH] + "..." + + if status == 401: + raise KeboolaApiError( + message=f"Invalid or expired token (token: {self._masked_token}): {api_message}", + status_code=status, + error_code="INVALID_TOKEN", + retryable=False, + ) + + if status == 403: + raise KeboolaApiError( + message=f"Access denied (token: {self._masked_token}): {api_message}", + status_code=status, + error_code="ACCESS_DENIED", + retryable=False, + ) + + if status == 404: + raise KeboolaApiError( + message=f"Resource not found: {api_message}", + status_code=status, + error_code="NOT_FOUND", + retryable=False, + ) + + retryable = status in RETRYABLE_STATUS_CODES + raise KeboolaApiError( + message=f"API error {status} from {url_label} (token: {self._masked_token}): {api_message}", + status_code=status, + error_code="API_ERROR", + retryable=retryable, + ) diff --git a/src/keboola_agent_cli/manage_client.py b/src/keboola_agent_cli/manage_client.py index d95ded83..e1f97cca 100644 --- a/src/keboola_agent_cli/manage_client.py +++ b/src/keboola_agent_cli/manage_client.py @@ -3,160 +3,48 @@ This module communicates with the Keboola Manage API for organization-level operations like listing projects and creating Storage API tokens. Uses a different auth header (X-KBC-ManageApiToken) than the Storage API client. + +Inherits shared retry/error logic from BaseHttpClient. """ -import time from typing import Any import httpx from . import __version__ -from .errors import KeboolaApiError, mask_token - -# Reuse same retry constants as the Storage API client -RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504} -MAX_RETRIES = 3 -BACKOFF_BASE = 1.0 # seconds; delays: 1s, 2s, 4s +from .constants import DEFAULT_TIMEOUT +from .http_base import BaseHttpClient -class ManageClient: +class ManageClient(BaseHttpClient): """HTTP client for the Keboola Manage API. Provides methods to list organization projects and create Storage API tokens, with built-in retry logic (exponential backoff for 429/5xx), timeouts, and automatic token masking in error messages. + + Inherits _do_request() and _raise_api_error() from BaseHttpClient. """ def __init__(self, stack_url: str, manage_token: str) -> None: self._stack_url = stack_url.rstrip("/") - self._manage_token = manage_token - self._masked_token = mask_token(manage_token) - self._client = httpx.Client( + headers = { + "X-KBC-ManageApiToken": manage_token, + "User-Agent": f"keboola-agent-cli/{__version__}", + } + super().__init__( base_url=self._stack_url, - timeout=httpx.Timeout(connect=5.0, read=30.0, write=10.0, pool=5.0), - headers={ - "X-KBC-ManageApiToken": manage_token, - "User-Agent": f"keboola-agent-cli/{__version__}", - }, + token=manage_token, + headers=headers, + timeout=DEFAULT_TIMEOUT, ) - def close(self) -> None: - """Close the underlying HTTP client.""" - self._client.close() - def __enter__(self) -> "ManageClient": return self def __exit__(self, *args: Any) -> None: self.close() - def _do_request(self, method: str, path: str, **kwargs: Any) -> httpx.Response: - """Execute an HTTP request with retry and exponential backoff. - - Retries on status codes 429, 500, 502, 503, 504 up to MAX_RETRIES times - with exponential backoff (1s, 2s, 4s). - - Raises: - KeboolaApiError: On HTTP errors (with masked token) or after retries exhausted. - """ - last_response: httpx.Response | None = None - - for attempt in range(MAX_RETRIES): - try: - response = self._client.request(method, path, **kwargs) - - if response.status_code < 400: - return response - - if response.status_code in RETRYABLE_STATUS_CODES and attempt < MAX_RETRIES - 1: - delay = BACKOFF_BASE * (2**attempt) - time.sleep(delay) - last_response = response - continue - - self._raise_api_error(response) - - except httpx.TimeoutException as exc: - if attempt < MAX_RETRIES - 1: - delay = BACKOFF_BASE * (2**attempt) - time.sleep(delay) - continue - raise KeboolaApiError( - message=f"Request timed out connecting to {self._stack_url} (token: {self._masked_token})", - status_code=0, - error_code="TIMEOUT", - retryable=True, - ) from exc - - except httpx.ConnectError as exc: - if attempt < MAX_RETRIES - 1: - delay = BACKOFF_BASE * (2**attempt) - time.sleep(delay) - continue - raise KeboolaApiError( - message=f"Cannot connect to {self._stack_url} (token: {self._masked_token})", - status_code=0, - error_code="CONNECTION_ERROR", - retryable=True, - ) from exc - - if last_response is not None: - self._raise_api_error(last_response) - - raise KeboolaApiError( - message=f"Request failed after {MAX_RETRIES} retries to {self._stack_url} (token: {self._masked_token})", - status_code=0, - error_code="RETRY_EXHAUSTED", - retryable=True, - ) - - def _raise_api_error(self, response: httpx.Response) -> None: - """Convert an HTTP error response into a KeboolaApiError.""" - status = response.status_code - - try: - body = response.json() - api_message = body.get("error", body.get("message", response.text)) - except Exception: - api_message = response.text - - # Truncate to prevent Rich markup injection and excessive output - max_api_error_length = 500 - if isinstance(api_message, str) and len(api_message) > max_api_error_length: - api_message = api_message[:max_api_error_length] + "..." - - if status == 401: - raise KeboolaApiError( - message=f"Invalid or expired manage token (token: {self._masked_token}): {api_message}", - status_code=status, - error_code="INVALID_TOKEN", - retryable=False, - ) - - if status == 403: - raise KeboolaApiError( - message=f"Access denied (token: {self._masked_token}): {api_message}", - status_code=status, - error_code="ACCESS_DENIED", - retryable=False, - ) - - if status == 404: - raise KeboolaApiError( - message=f"Resource not found: {api_message}", - status_code=status, - error_code="NOT_FOUND", - retryable=False, - ) - - retryable = status in RETRYABLE_STATUS_CODES - raise KeboolaApiError( - message=f"API error {status} from {self._stack_url} (token: {self._masked_token}): {api_message}", - status_code=status, - error_code="API_ERROR", - retryable=retryable, - ) - def list_organization_projects(self, org_id: int) -> list[dict[str, Any]]: """List all projects in an organization. diff --git a/src/keboola_agent_cli/services/base.py b/src/keboola_agent_cli/services/base.py index 99c6d4c1..f052ec5d 100644 --- a/src/keboola_agent_cli/services/base.py +++ b/src/keboola_agent_cli/services/base.py @@ -11,12 +11,10 @@ from ..client import KeboolaClient from ..config_store import ConfigStore +from ..constants import ENV_MAX_PARALLEL_WORKERS from ..errors import ConfigError from ..models import ProjectConfig -# Env var name for overriding max_parallel_workers from config.json -ENV_MAX_PARALLEL_WORKERS = "KBAGENT_MAX_PARALLEL_WORKERS" - ClientFactory = Callable[[str, str], KeboolaClient] diff --git a/src/keboola_agent_cli/services/job_service.py b/src/keboola_agent_cli/services/job_service.py index cf807a58..0da91bda 100644 --- a/src/keboola_agent_cli/services/job_service.py +++ b/src/keboola_agent_cli/services/job_service.py @@ -6,6 +6,7 @@ from typing import Any +from ..constants import DEFAULT_JOB_LIMIT from ..errors import KeboolaApiError from ..models import ProjectConfig from .base import BaseService @@ -28,7 +29,7 @@ def _fetch_project_jobs( component_id: str | None = None, config_id: str | None = None, status: str | None = None, - limit: int = 50, + limit: int = DEFAULT_JOB_LIMIT, ) -> tuple[str, list[dict[str, Any]], bool] | tuple[str, dict[str, str]]: """Fetch jobs for a single project (runs in a worker thread). @@ -74,7 +75,7 @@ def list_jobs( component_id: str | None = None, config_id: str | None = None, status: str | None = None, - limit: int = 50, + limit: int = DEFAULT_JOB_LIMIT, ) -> dict[str, Any]: """List jobs across one or multiple projects. diff --git a/src/keboola_agent_cli/services/org_service.py b/src/keboola_agent_cli/services/org_service.py index 3c34983f..849ef270 100644 --- a/src/keboola_agent_cli/services/org_service.py +++ b/src/keboola_agent_cli/services/org_service.py @@ -10,6 +10,7 @@ from ..client import KeboolaClient from ..config_store import ConfigStore +from ..constants import DEFAULT_TOKEN_DESCRIPTION from ..errors import KeboolaApiError, mask_token from ..manage_client import ManageClient from ..models import ProjectConfig @@ -70,7 +71,7 @@ def setup_organization( stack_url: str, manage_token: str, org_id: int, - token_description: str = "kbagent-cli", + token_description: str = DEFAULT_TOKEN_DESCRIPTION, dry_run: bool = False, ) -> dict[str, Any]: """Set up all projects from a Keboola organization. diff --git a/tests/test_client.py b/tests/test_client.py index 7e2712ed..24cb37c4 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -5,7 +5,8 @@ import httpx import pytest -from keboola_agent_cli.client import MAX_RETRIES, KeboolaClient +from keboola_agent_cli.client import KeboolaClient +from keboola_agent_cli.constants import MAX_RETRIES from keboola_agent_cli.errors import KeboolaApiError VERIFY_TOKEN_RESPONSE = { @@ -105,15 +106,15 @@ def test_retry_on_503_then_success(self, httpx_mock) -> None: ) # Monkeypatch time.sleep to avoid actual delays in tests - import keboola_agent_cli.client as client_module + import keboola_agent_cli.http_base as http_base_module - original_sleep = client_module.time.sleep - client_module.time.sleep = lambda x: None + original_sleep = http_base_module.time.sleep + http_base_module.time.sleep = lambda x: None try: result = client.verify_token() assert result.project_name == "Test Project" finally: - client_module.time.sleep = original_sleep + http_base_module.time.sleep = original_sleep client.close() def test_retry_exhausted_raises_error(self, httpx_mock) -> None: @@ -130,16 +131,16 @@ def test_retry_exhausted_raises_error(self, httpx_mock) -> None: token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", ) - import keboola_agent_cli.client as client_module + import keboola_agent_cli.http_base as http_base_module - original_sleep = client_module.time.sleep - client_module.time.sleep = lambda x: None + original_sleep = http_base_module.time.sleep + http_base_module.time.sleep = lambda x: None try: with pytest.raises(KeboolaApiError) as exc_info: client.verify_token() assert exc_info.value.retryable is True finally: - client_module.time.sleep = original_sleep + http_base_module.time.sleep = original_sleep client.close() def test_retry_on_429(self, httpx_mock) -> None: @@ -160,15 +161,15 @@ def test_retry_on_429(self, httpx_mock) -> None: token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", ) - import keboola_agent_cli.client as client_module + import keboola_agent_cli.http_base as http_base_module - original_sleep = client_module.time.sleep - client_module.time.sleep = lambda x: None + original_sleep = http_base_module.time.sleep + http_base_module.time.sleep = lambda x: None try: result = client.verify_token() assert result.project_name == "Test Project" finally: - client_module.time.sleep = original_sleep + http_base_module.time.sleep = original_sleep client.close() def test_no_retry_on_400(self, httpx_mock) -> None: @@ -215,17 +216,17 @@ def test_timeout_raises_api_error(self, httpx_mock) -> None: token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", ) - import keboola_agent_cli.client as client_module + import keboola_agent_cli.http_base as http_base_module - original_sleep = client_module.time.sleep - client_module.time.sleep = lambda x: None + original_sleep = http_base_module.time.sleep + http_base_module.time.sleep = lambda x: None try: with pytest.raises(KeboolaApiError) as exc_info: client.verify_token() assert exc_info.value.error_code == "TIMEOUT" assert exc_info.value.retryable is True finally: - client_module.time.sleep = original_sleep + http_base_module.time.sleep = original_sleep client.close() def test_connect_error_raises_api_error(self, httpx_mock) -> None: @@ -248,17 +249,17 @@ def test_connect_error_raises_api_error(self, httpx_mock) -> None: token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", ) - import keboola_agent_cli.client as client_module + import keboola_agent_cli.http_base as http_base_module - original_sleep = client_module.time.sleep - client_module.time.sleep = lambda x: None + original_sleep = http_base_module.time.sleep + http_base_module.time.sleep = lambda x: None try: with pytest.raises(KeboolaApiError) as exc_info: client.verify_token() assert exc_info.value.error_code == "CONNECTION_ERROR" assert exc_info.value.retryable is True finally: - client_module.time.sleep = original_sleep + http_base_module.time.sleep = original_sleep client.close() @@ -302,17 +303,17 @@ def test_timeout_error_masks_token(self, httpx_mock) -> None: token=full_token, ) - import keboola_agent_cli.client as client_module + import keboola_agent_cli.http_base as http_base_module - original_sleep = client_module.time.sleep - client_module.time.sleep = lambda x: None + original_sleep = http_base_module.time.sleep + http_base_module.time.sleep = lambda x: None try: with pytest.raises(KeboolaApiError) as exc_info: client.verify_token() assert full_token not in exc_info.value.message assert "901-...pt0k" in exc_info.value.message finally: - client_module.time.sleep = original_sleep + http_base_module.time.sleep = original_sleep client.close() @@ -456,10 +457,10 @@ def test_malformed_json_in_error_response(self, httpx_mock) -> None: status_code=502, ) - import keboola_agent_cli.client as client_module + import keboola_agent_cli.http_base as http_base_module - original_sleep = client_module.time.sleep - client_module.time.sleep = lambda x: None + original_sleep = http_base_module.time.sleep + http_base_module.time.sleep = lambda x: None try: with KeboolaClient( stack_url="https://connection.keboola.com", @@ -471,7 +472,7 @@ def test_malformed_json_in_error_response(self, httpx_mock) -> None: # Error message should contain the raw text body assert "502" in exc_info.value.message finally: - client_module.time.sleep = original_sleep + http_base_module.time.sleep = original_sleep def test_malformed_json_in_success_response(self, httpx_mock) -> None: """Client raises error when success response has non-parseable JSON.""" @@ -531,10 +532,10 @@ def test_empty_body_error_response(self, httpx_mock) -> None: status_code=500, ) - import keboola_agent_cli.client as client_module + import keboola_agent_cli.http_base as http_base_module - original_sleep = client_module.time.sleep - client_module.time.sleep = lambda x: None + original_sleep = http_base_module.time.sleep + http_base_module.time.sleep = lambda x: None try: with KeboolaClient( stack_url="https://connection.keboola.com", @@ -545,7 +546,7 @@ def test_empty_body_error_response(self, httpx_mock) -> None: assert exc_info.value.retryable is True assert exc_info.value.status_code == 500 finally: - client_module.time.sleep = original_sleep + http_base_module.time.sleep = original_sleep def test_empty_components_list(self, httpx_mock) -> None: """list_components returns empty list when API returns empty array.""" @@ -815,10 +816,10 @@ def test_list_jobs_retry_on_503(self, httpx_mock) -> None: status_code=200, ) - import keboola_agent_cli.client as client_module + import keboola_agent_cli.http_base as http_base_module - original_sleep = client_module.time.sleep - client_module.time.sleep = lambda x: None + original_sleep = http_base_module.time.sleep + http_base_module.time.sleep = lambda x: None try: with KeboolaClient( stack_url="https://connection.keboola.com", @@ -827,7 +828,7 @@ def test_list_jobs_retry_on_503(self, httpx_mock) -> None: result = client.list_jobs() assert len(result) == 1 finally: - client_module.time.sleep = original_sleep + http_base_module.time.sleep = original_sleep def test_list_jobs_empty_result(self, httpx_mock) -> None: """list_jobs() returns empty list when no jobs match.""" diff --git a/tests/test_http_base.py b/tests/test_http_base.py new file mode 100644 index 00000000..69ccb865 --- /dev/null +++ b/tests/test_http_base.py @@ -0,0 +1,436 @@ +"""Tests for BaseHttpClient - retry logic, error sanitization, shared HTTP behavior.""" + +import httpx +import pytest + +from keboola_agent_cli.constants import MAX_API_ERROR_LENGTH, MAX_RETRIES +from keboola_agent_cli.errors import KeboolaApiError +from keboola_agent_cli.http_base import BaseHttpClient + +STACK_URL = "https://connection.keboola.com" +TOKEN = "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k" + + +class TestBaseHttpClientRetry: + """Verify retry logic works via the base class.""" + + def test_retry_on_503_then_success(self, httpx_mock) -> None: + """BaseHttpClient retries on 503 and succeeds on subsequent attempt.""" + httpx_mock.add_response( + url=f"{STACK_URL}/test-path", + status_code=503, + text="Service Unavailable", + ) + httpx_mock.add_response( + url=f"{STACK_URL}/test-path", + json={"status": "ok"}, + status_code=200, + ) + + client = BaseHttpClient( + base_url=STACK_URL, + token=TOKEN, + headers={"Authorization": f"Bearer {TOKEN}"}, + ) + + import keboola_agent_cli.http_base as http_base_module + + original_sleep = http_base_module.time.sleep + http_base_module.time.sleep = lambda x: None + try: + response = client._do_request("GET", "/test-path") + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + assert len(httpx_mock.get_requests()) == 2 + finally: + http_base_module.time.sleep = original_sleep + client.close() + + def test_retry_exhausted_raises_error(self, httpx_mock) -> None: + """BaseHttpClient raises KeboolaApiError after exhausting retries on persistent 500.""" + for _ in range(MAX_RETRIES): + httpx_mock.add_response( + url=f"{STACK_URL}/test-path", + status_code=500, + text="Internal Server Error", + ) + + client = BaseHttpClient( + base_url=STACK_URL, + token=TOKEN, + headers={"Authorization": f"Bearer {TOKEN}"}, + ) + + import keboola_agent_cli.http_base as http_base_module + + original_sleep = http_base_module.time.sleep + http_base_module.time.sleep = lambda x: None + try: + with pytest.raises(KeboolaApiError) as exc_info: + client._do_request("GET", "/test-path") + assert exc_info.value.retryable is True + assert exc_info.value.status_code == 500 + assert len(httpx_mock.get_requests()) == MAX_RETRIES + finally: + http_base_module.time.sleep = original_sleep + client.close() + + def test_retry_on_429_rate_limit(self, httpx_mock) -> None: + """BaseHttpClient retries on 429 and succeeds.""" + httpx_mock.add_response( + url=f"{STACK_URL}/test-path", + status_code=429, + text="Rate limit exceeded", + ) + httpx_mock.add_response( + url=f"{STACK_URL}/test-path", + json={"result": "ok"}, + status_code=200, + ) + + client = BaseHttpClient( + base_url=STACK_URL, + token=TOKEN, + headers={"Authorization": f"Bearer {TOKEN}"}, + ) + + import keboola_agent_cli.http_base as http_base_module + + original_sleep = http_base_module.time.sleep + http_base_module.time.sleep = lambda x: None + try: + response = client._do_request("GET", "/test-path") + assert response.status_code == 200 + assert len(httpx_mock.get_requests()) == 2 + finally: + http_base_module.time.sleep = original_sleep + client.close() + + def test_no_retry_on_400(self, httpx_mock) -> None: + """BaseHttpClient does NOT retry on 400.""" + httpx_mock.add_response( + url=f"{STACK_URL}/test-path", + status_code=400, + json={"error": "Bad request"}, + ) + + client = BaseHttpClient( + base_url=STACK_URL, + token=TOKEN, + headers={"Authorization": f"Bearer {TOKEN}"}, + ) + + with pytest.raises(KeboolaApiError) as exc_info: + client._do_request("GET", "/test-path") + + assert exc_info.value.status_code == 400 + assert exc_info.value.retryable is False + assert len(httpx_mock.get_requests()) == 1 + client.close() + + def test_timeout_retries_then_raises(self, httpx_mock) -> None: + """BaseHttpClient retries on timeout and raises TIMEOUT error after exhaustion.""" + for _ in range(MAX_RETRIES): + httpx_mock.add_exception( + httpx.ReadTimeout("Read timed out"), + url=f"{STACK_URL}/test-path", + ) + + client = BaseHttpClient( + base_url=STACK_URL, + token=TOKEN, + headers={"Authorization": f"Bearer {TOKEN}"}, + ) + + import keboola_agent_cli.http_base as http_base_module + + original_sleep = http_base_module.time.sleep + http_base_module.time.sleep = lambda x: None + try: + with pytest.raises(KeboolaApiError) as exc_info: + client._do_request("GET", "/test-path") + assert exc_info.value.error_code == "TIMEOUT" + assert exc_info.value.retryable is True + finally: + http_base_module.time.sleep = original_sleep + client.close() + + def test_connect_error_retries_then_raises(self, httpx_mock) -> None: + """BaseHttpClient retries on connection error and raises after exhaustion.""" + for _ in range(MAX_RETRIES): + httpx_mock.add_exception( + httpx.ConnectError("Connection refused"), + url=f"{STACK_URL}/test-path", + ) + + client = BaseHttpClient( + base_url=STACK_URL, + token=TOKEN, + headers={"Authorization": f"Bearer {TOKEN}"}, + ) + + import keboola_agent_cli.http_base as http_base_module + + original_sleep = http_base_module.time.sleep + http_base_module.time.sleep = lambda x: None + try: + with pytest.raises(KeboolaApiError) as exc_info: + client._do_request("GET", "/test-path") + assert exc_info.value.error_code == "CONNECTION_ERROR" + assert exc_info.value.retryable is True + finally: + http_base_module.time.sleep = original_sleep + client.close() + + def test_alternate_client_parameter(self, httpx_mock) -> None: + """_do_request accepts alternate client and base_url for queue-like usage.""" + httpx_mock.add_response( + url="https://queue.keboola.com/test-path", + json={"queue": True}, + status_code=200, + ) + + base_client = BaseHttpClient( + base_url=STACK_URL, + token=TOKEN, + headers={"Authorization": f"Bearer {TOKEN}"}, + ) + alt_client = httpx.Client( + base_url="https://queue.keboola.com", + headers={"Authorization": f"Bearer {TOKEN}"}, + ) + try: + response = base_client._do_request( + "GET", "/test-path", + client=alt_client, + base_url="https://queue.keboola.com", + ) + assert response.status_code == 200 + assert response.json() == {"queue": True} + finally: + alt_client.close() + base_client.close() + + +class TestBaseHttpClientErrorSanitization: + """Verify message truncation and error mapping in the base class.""" + + def test_long_error_message_truncated(self, httpx_mock) -> None: + """API error messages longer than MAX_API_ERROR_LENGTH are truncated.""" + long_message = "A" * 1000 + httpx_mock.add_response( + url=f"{STACK_URL}/test-path", + json={"error": long_message}, + status_code=400, + ) + + client = BaseHttpClient( + base_url=STACK_URL, + token=TOKEN, + headers={"Authorization": f"Bearer {TOKEN}"}, + ) + + with pytest.raises(KeboolaApiError) as exc_info: + client._do_request("GET", "/test-path") + + # Full 1000-char message should NOT appear + assert long_message not in exc_info.value.message + # Truncated message (500 chars + "...") should be present + assert "A" * MAX_API_ERROR_LENGTH + "..." in exc_info.value.message + client.close() + + def test_short_error_message_not_truncated(self, httpx_mock) -> None: + """Short API error messages are kept intact.""" + short_message = "Bad request" + httpx_mock.add_response( + url=f"{STACK_URL}/test-path", + json={"error": short_message}, + status_code=400, + ) + + client = BaseHttpClient( + base_url=STACK_URL, + token=TOKEN, + headers={"Authorization": f"Bearer {TOKEN}"}, + ) + + with pytest.raises(KeboolaApiError) as exc_info: + client._do_request("GET", "/test-path") + + assert short_message in exc_info.value.message + client.close() + + def test_exactly_max_length_not_truncated(self, httpx_mock) -> None: + """Error message of exactly MAX_API_ERROR_LENGTH is not truncated.""" + exact_message = "B" * MAX_API_ERROR_LENGTH + httpx_mock.add_response( + url=f"{STACK_URL}/test-path", + json={"error": exact_message}, + status_code=400, + ) + + client = BaseHttpClient( + base_url=STACK_URL, + token=TOKEN, + headers={"Authorization": f"Bearer {TOKEN}"}, + ) + + with pytest.raises(KeboolaApiError) as exc_info: + client._do_request("GET", "/test-path") + + # Exactly MAX_API_ERROR_LENGTH chars should not be truncated + assert exact_message in exc_info.value.message + client.close() + + def test_rich_markup_contained_by_truncation(self, httpx_mock) -> None: + """Rich markup brackets in error messages are contained by truncation.""" + malicious_msg = "[bold red]" + "X" * 600 + "[/bold red]" + httpx_mock.add_response( + url=f"{STACK_URL}/test-path", + json={"error": malicious_msg}, + status_code=400, + ) + + client = BaseHttpClient( + base_url=STACK_URL, + token=TOKEN, + headers={"Authorization": f"Bearer {TOKEN}"}, + ) + + with pytest.raises(KeboolaApiError) as exc_info: + client._do_request("GET", "/test-path") + + # Full malicious markup should not appear + assert malicious_msg not in exc_info.value.message + # Should be truncated + assert "..." in exc_info.value.message + client.close() + + def test_non_json_error_body_handled(self, httpx_mock) -> None: + """Non-JSON error response body is handled gracefully.""" + httpx_mock.add_response( + url=f"{STACK_URL}/test-path", + text="500 Internal Error", + status_code=400, + ) + + client = BaseHttpClient( + base_url=STACK_URL, + token=TOKEN, + headers={"Authorization": f"Bearer {TOKEN}"}, + ) + + with pytest.raises(KeboolaApiError) as exc_info: + client._do_request("GET", "/test-path") + + assert "500" in exc_info.value.message + client.close() + + def test_401_maps_to_invalid_token(self, httpx_mock) -> None: + """401 status code maps to INVALID_TOKEN error code.""" + httpx_mock.add_response( + url=f"{STACK_URL}/test-path", + json={"error": "Invalid token"}, + status_code=401, + ) + + client = BaseHttpClient( + base_url=STACK_URL, + token=TOKEN, + headers={"Authorization": f"Bearer {TOKEN}"}, + ) + + with pytest.raises(KeboolaApiError) as exc_info: + client._do_request("GET", "/test-path") + + assert exc_info.value.error_code == "INVALID_TOKEN" + assert exc_info.value.status_code == 401 + assert exc_info.value.retryable is False + client.close() + + def test_403_maps_to_access_denied(self, httpx_mock) -> None: + """403 status code maps to ACCESS_DENIED error code.""" + httpx_mock.add_response( + url=f"{STACK_URL}/test-path", + json={"error": "Forbidden"}, + status_code=403, + ) + + client = BaseHttpClient( + base_url=STACK_URL, + token=TOKEN, + headers={"Authorization": f"Bearer {TOKEN}"}, + ) + + with pytest.raises(KeboolaApiError) as exc_info: + client._do_request("GET", "/test-path") + + assert exc_info.value.error_code == "ACCESS_DENIED" + assert exc_info.value.status_code == 403 + assert exc_info.value.retryable is False + client.close() + + def test_404_maps_to_not_found(self, httpx_mock) -> None: + """404 status code maps to NOT_FOUND error code.""" + httpx_mock.add_response( + url=f"{STACK_URL}/test-path", + json={"error": "Not found"}, + status_code=404, + ) + + client = BaseHttpClient( + base_url=STACK_URL, + token=TOKEN, + headers={"Authorization": f"Bearer {TOKEN}"}, + ) + + with pytest.raises(KeboolaApiError) as exc_info: + client._do_request("GET", "/test-path") + + assert exc_info.value.error_code == "NOT_FOUND" + assert exc_info.value.status_code == 404 + assert exc_info.value.retryable is False + client.close() + + def test_token_masked_in_error_messages(self, httpx_mock) -> None: + """Full token never appears in error messages.""" + httpx_mock.add_response( + url=f"{STACK_URL}/test-path", + json={"error": "Some error"}, + status_code=401, + ) + + client = BaseHttpClient( + base_url=STACK_URL, + token=TOKEN, + headers={"Authorization": f"Bearer {TOKEN}"}, + ) + + with pytest.raises(KeboolaApiError) as exc_info: + client._do_request("GET", "/test-path") + + # Full token must NOT appear in the error message + assert TOKEN not in exc_info.value.message + # Masked form should appear + assert "901-...pt0k" in exc_info.value.message + client.close() + + +class TestBaseHttpClientContextManager: + """Test context manager protocol on BaseHttpClient.""" + + def test_context_manager(self, httpx_mock) -> None: + """BaseHttpClient works as a context manager.""" + httpx_mock.add_response( + url=f"{STACK_URL}/test-path", + json={"ok": True}, + status_code=200, + ) + + with BaseHttpClient( + base_url=STACK_URL, + token=TOKEN, + headers={"Authorization": f"Bearer {TOKEN}"}, + ) as client: + response = client._do_request("GET", "/test-path") + assert response.status_code == 200