Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
05662ac
feat(DEVC-1752): added requests.Session reuse; replaced third-party t…
kossman Jul 23, 2025
1db2d26
feat(DEVC-1752): fix lint
kossman Jul 23, 2025
be9cb65
feat(DEVC-1752): fix lint
kossman Jul 23, 2025
474612d
feat(DEVC-1752): fix format
kossman Jul 23, 2025
bc00f69
feat(DEVC-1752): fix format
kossman Jul 23, 2025
41064cd
feat(DEVC-1752): bump version for fakeredis since py3.8 no longer sup…
kossman Jul 23, 2025
2ce6d39
feat(DEVC-1752): remove redundant tests according to new schema for r…
kossman Jul 25, 2025
1247160
feat(DEVC-1752): fix format
kossman Jul 25, 2025
b6d72f9
feat(DEVC-1752): skip some tests on py3.13 regarding logging/capsys
kossman Jul 25, 2025
1d1623f
feat(DEVC-1752): fix format
kossman Jul 25, 2025
a7bc9db
Revert "feat(DEVC-1752): fix format"
kossman Jul 25, 2025
98e9bc8
feat(DEVC-1752): try again
kossman Jul 25, 2025
8d9476b
feat(DEVC-1752): try to bump pytest version to more fresh
kossman Jul 25, 2025
cb15d1e
feat(DEVC-1752): try to bump 3.13 version
kossman Jul 25, 2025
40dd5cc
feat(DEVC-1752): try to bump 3.13 version
kossman Jul 25, 2025
5902deb
eat(DEVC-1752): try to bump 3.13 version
kossman Jul 25, 2025
9fae758
feat(DEVC-1752): try to bump 3.13 version to 3.13.4
kossman Jul 25, 2025
de5ad56
feat(DEVC-1752): try to bump 3.13 version to 3.13.3
kossman Jul 25, 2025
15530ed
feat(DEVC-1752): remove unused dependency tenacity for python-sdk
kossman Jul 25, 2025
5483f76
feat(DEVC-1752): update changelog
kossman Jul 25, 2025
0be9d54
feat(DEVC-1752): add adjusting debug level for `urllib3.connectionpoo…
kossman Jul 25, 2025
3b3f761
feat(DEVC-1752): pre release
kossman Jul 25, 2025
91051e4
feat(DEVC-1752): pre release
kossman Jul 25, 2025
6e54eb5
feat(DEVC-1752): unify retry logic with existing NodeJS way
kossman Jul 25, 2025
348771a
feat(DEVC-1752): fix linter
kossman Jul 25, 2025
5290ae5
feat(DEVC-1752): fix format
kossman Jul 25, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name: CI
on: push

env:
PYTHON_VERSIONS: '[ "3.8", "3.9", "3.10", "3.11", "3.12", "3.13" ]'
PYTHON_VERSIONS: '[ "3.8", "3.9", "3.10", "3.11", "3.12", "3.13.3" ]'

jobs:

Expand Down
17 changes: 16 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [1.14.1] - 2025-07-25
### Added
- Session mechanism for significantly decrease number of an http load on data-api for apps with intensive calling
- Added possibility to adjust some params related to connection pool
- `POOL_CONNECTIONS_COUNT`: Total pools count
- `POOL_MAX_SIZE`: Max connections count per pool/host
- `POOL_BLOCK`: Wait until connection released or not (instantly raise an exception)
- `MAX_RETRY_COUNT`: If 0 then retires will be disabled, otherwise retrying logic will be used
- Move retrying logic from `tenacity` to internal `urllib3.util.Retry(...)`
- Removed redundant dependency `tenacity` from `python-sdk`
- Bump version for `py3.13` to `py3.13.3` at CI version matrix in order to fix broken tests for logging
- Bump version for `fakeredis` to fix some tests


## [1.14.0] - 2025-04-17
### Fixed
- merge_events parameter for scheduled data time apps should result in correct start/end times in a final app event.
Expand Down Expand Up @@ -405,7 +419,8 @@ env variables, that should be used to configure logging.
- Event classes: `StreamEvent`, `ScheduledEvent` and `TaskEvent`.


[Unreleased] https://github.com/corva-ai/python-sdk/compare/v1.14.0...master
[Unreleased] https://github.com/corva-ai/python-sdk/compare/v1.14.1...master
[1.14.1] https://github.com/corva-ai/python-sdk/compare/v1.14.0...v1.14.1
[1.14.0] https://github.com/corva-ai/python-sdk/compare/v1.13.1...v1.14.0
[1.13.1] https://github.com/corva-ai/python-sdk/compare/v1.13.0...v1.13.1
[1.13.0] https://github.com/corva-ai/python-sdk/compare/v1.12.1...v1.13.0
Expand Down
2 changes: 1 addition & 1 deletion docs/antora-playbook.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ content:
start_path: docs
branches: []
# branches: HEAD # Use this for local development
tags: [v1.14.0]
tags: [v1.14.1]
asciidoc:
attributes:
page-toclevels: 5
Expand Down
2 changes: 1 addition & 1 deletion docs/antora.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
name: corva-sdk
version: ~
version: 1.14.1
nav: [modules/ROOT/nav.adoc]
7 changes: 0 additions & 7 deletions docs/modules/ROOT/examples/api/tutorial008.py

This file was deleted.

3 changes: 1 addition & 2 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,11 @@
packages=setuptools.find_packages("src"),
package_dir={"": "src"},
install_requires=[
"fakeredis[lua] >=2.26.2, <3.0.0",
"fakeredis[lua] >=2.26.2, <2.30.0",
"pydantic >=1.8.2, <2.0.0",
"redis >=5.2.1, <6.0.0",
"requests >=2.32.3, <3.0.0",
"urllib3 <2", # lambda doesnt support version 2 yet
"tenacity >=8.2.3, <9.0.0",
],
python_requires='>=3.8, <4.0',
license='The Unlicense',
Expand Down
110 changes: 29 additions & 81 deletions src/corva/api.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,12 @@
import json
import posixpath
import re
from http import HTTPStatus
from typing import List, Optional, Sequence, Union

import requests
from tenacity import (
RetryError,
retry,
retry_if_result,
stop_after_attempt,
wait_random_exponential,
)

from corva.api_utils import get_requests_session, get_retry_strategy
from corva.configuration import SETTINGS


class Api:
Expand All @@ -21,26 +16,34 @@ class Api:
convenient URL usage and reasonable timeouts to API requests.
"""

TIMEOUT_LIMITS = (3, 30) # seconds
DEFAULT_MAX_RETRIES = int(0)

def __init__(
self,
*,
api_url: str,
data_api_url: str,
api_key: str,
app_key: str,
timeout: Optional[int] = None,
app_connection_id: Optional[int] = None,
max_retries: Optional[int] = 3,
backoff_factor_retries: Optional[float] = 1,
pool_conn_count: Optional[int] = None,
pool_max_size: Optional[int] = None,
pool_block: Optional[bool] = None,
):
self.api_url = api_url
self.data_api_url = data_api_url
self.api_key = api_key
self.app_key = app_key
self.app_connection_id = app_connection_id
self.timeout = timeout or self.TIMEOUT_LIMITS[1]
self._max_retries = self.DEFAULT_MAX_RETRIES
self._session = get_requests_session(
retry_strategy=get_retry_strategy(
max_retries=max_retries or SETTINGS.MAX_RETRY_COUNT,
backoff_factor=backoff_factor_retries or SETTINGS.BACKOFF_FACTOR,
),
pool_connections_count=(pool_conn_count or SETTINGS.POOL_CONNECTIONS_COUNT),
pool_max_size=pool_max_size or SETTINGS.POOL_MAX_SIZE,
pool_block=pool_block or SETTINGS.POOL_BLOCK,
)

@property
def default_headers(self):
Expand All @@ -49,16 +52,6 @@ def default_headers(self):
"X-Corva-App": self.app_key,
}

@property
def max_retries(self) -> int:
return self._max_retries

@max_retries.setter
def max_retries(self, value: int):
if not (0 <= value <= 10):
raise ValueError("Values between 0 and 10 are allowed")
self._max_retries = value

def get(self, path: str, **kwargs):
return self._request("GET", path, **kwargs)

Expand Down Expand Up @@ -99,15 +92,15 @@ def _get_url(self, path: str):

return posixpath.join(self.api_url, path)

@staticmethod
def _execute_request(
self,
method: str,
url: str,
params: Optional[dict],
data: Optional[dict],
headers: Optional[dict] = None,
timeout: Optional[int] = None,
):
) -> requests.Response:
"""Executes the request.

Args:
Expand All @@ -116,12 +109,12 @@ def _execute_request(
data: request body, that will be casted to json.
params: url query string params.
headers: additional headers to include in request.
timeout: custom request timeout in seconds.

Returns:
requests.Response instance.
"""
return requests.request(

return self._session.request(
method=method,
url=url,
params=params,
Expand All @@ -148,70 +141,25 @@ def _request(
data: request body, that will be casted to json.
params: url query string params.
headers: additional headers to include in request.
timeout: custom request timeout in seconds.

Returns:
requests.Response instance.
"""
retryable_status_codes = [
HTTPStatus.TOO_MANY_REQUESTS, # 428
HTTPStatus.INTERNAL_SERVER_ERROR, # 500
HTTPStatus.BAD_GATEWAY, # 502
HTTPStatus.SERVICE_UNAVAILABLE, # 503
HTTPStatus.GATEWAY_TIMEOUT, # 504
]

timeout = timeout or self.timeout
self._validate_timeout(timeout)

url = self._get_url(path)

headers = {
**self.default_headers,
**(headers or {}),
}

if self.max_retries > 0:
retry_decorator = retry(
stop=stop_after_attempt(self.max_retries),
wait=wait_random_exponential(multiplier=0.25, max=10),
retry=retry_if_result(
lambda r: r.status_code in retryable_status_codes
),
)
retrying_request = retry_decorator(self._execute_request)
try:
response = retrying_request(
method=method,
url=url,
params=params,
data=data,
headers=headers,
timeout=timeout,
)
except RetryError as e:
if not e.last_attempt.failed:
response = e.last_attempt.result()
else:
raise
else:
response = self._execute_request(
method=method,
url=url,
params=params,
data=data,
headers=headers,
timeout=timeout,
)

return response

def _validate_timeout(self, timeout: int) -> None:
if self.TIMEOUT_LIMITS[0] > timeout or self.TIMEOUT_LIMITS[1] < timeout:
raise ValueError(
f"Timeout must be between {self.TIMEOUT_LIMITS[0]} and "
f"{self.TIMEOUT_LIMITS[1]} seconds."
)
return self._execute_request(
method=method,
url=url,
params=params,
data=data,
headers=headers,
timeout=timeout,
)

def get_dataset(
self,
Expand Down
57 changes: 57 additions & 0 deletions src/corva/api_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
from typing import Optional

import requests
from requests.adapters import HTTPAdapter
from urllib3 import Retry

RETRYABLE_STATUS_CODES = (
429, # HTTPStatus.TOO_MANY_REQUESTS
500, # HTTPStatus.INTERNAL_SERVER_ERROR
502, # HTTPStatus.BAD_GATEWAY
503, # HTTPStatus.SERVICE_UNAVAILABLE
504, # HTTPStatus.GATEWAY_TIMEOUT
)

# All HTTP methods allowed, see this discussion:
# https://corva.slack.com/archives/C0411LUPVL6/p1753451234091869
ALLOWED_RETRY_METHODS = (
"GET",
"POST",
"PUT",
"PATCH",
"DELETE",
"OPTIONS",
"HEAD",
"TRACE",
)


def get_retry_strategy(max_retries: int, backoff_factor: float = 1) -> Retry:
return Retry(
total=max_retries,
backoff_factor=backoff_factor,
status_forcelist=RETRYABLE_STATUS_CODES,
raise_on_status=False,
allowed_methods=ALLOWED_RETRY_METHODS,
)


def get_requests_session(
pool_connections_count: int,
pool_max_size: int,
pool_block: bool,
retry_strategy: Optional[Retry] = None,
) -> requests.Session:
adapter = HTTPAdapter(
max_retries=retry_strategy,
pool_connections=pool_connections_count,
pool_maxsize=pool_max_size,
pool_block=pool_block,
)

session = requests.Session()

session.mount('https://', adapter)
session.mount('http://', adapter)

return session
9 changes: 9 additions & 0 deletions src/corva/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,14 @@ class Settings(pydantic.BaseSettings):
# secrets
SECRETS_CACHE_TTL: int = int(datetime.timedelta(minutes=5).total_seconds())

# keep-alive
POOL_CONNECTIONS_COUNT: int = 20 # Total pools count
POOL_MAX_SIZE: int = 20 # Max connections count per pool/host
POOL_BLOCK: bool = True # Wait until connection released

# retry
MAX_RETRY_COUNT: int = 3 # If `0` then retries will be disabled
BACKOFF_FACTOR: float = 1.0


SETTINGS = Settings()
3 changes: 0 additions & 3 deletions src/corva/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,6 @@ def wrapper(
data_api_url=SETTINGS.DATA_API_ROOT_URL,
api_key=api_key,
app_key=SETTINGS.APP_KEY,
timeout=None,
app_connection_id=event.app_connection_id,
)

Expand Down Expand Up @@ -282,7 +281,6 @@ def wrapper(
data_api_url=SETTINGS.DATA_API_ROOT_URL,
api_key=api_key,
app_key=SETTINGS.APP_KEY,
timeout=None,
app_connection_id=event.app_connection_id,
)

Expand Down Expand Up @@ -392,7 +390,6 @@ def wrapper(
data_api_url=SETTINGS.DATA_API_ROOT_URL,
api_key=api_key,
app_key=SETTINGS.APP_KEY,
timeout=None,
app_connection_id=None,
)

Expand Down
2 changes: 2 additions & 0 deletions src/corva/logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
CORVA_LOGGER = logging.getLogger('corva')
CORVA_LOGGER.setLevel(SETTINGS.LOG_LEVEL)

logging.getLogger("urllib3.connectionpool").setLevel(SETTINGS.LOG_LEVEL)

# unset to pass messages to ancestor loggers, including OTel Log Sending handler
# see https://github.com/corva-ai/otel/pull/37
# see https://corvaqa.atlassian.net/browse/EE-31
Expand Down
2 changes: 1 addition & 1 deletion src/version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
VERSION = "1.14.0"
VERSION = "1.14.1"
Loading