Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions qase-python-commons/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,12 @@ When a 429 carries a `Retry-After` header, that value replaces the computed
backoff. Qase sends roughly 60 seconds, so a run that hits the rate limit takes
longer to finish rather than losing the batch.

The same timeout and retry settings apply to attachment uploads, which go
through the API v1 client in batches of up to 20 files. **If an attachment batch
cannot be uploaded after all attempts**, the reporter logs an error, drops those
attachments and still submits the results they belong to — a failed attachment
never costs you the test results.

**If a batch cannot be delivered after all attempts**, the reporter logs an
error naming how many results were lost and **does not mark the run complete**.
An open run is the signal that its data is incomplete; a completed run over
Expand Down
7 changes: 7 additions & 0 deletions qase-python-commons/changelog.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
# qase-python-commons@5.1.5

## What's new

- Fixed a hang where an attachment upload could block a reporter thread forever ([#513](https://github.com/qase-tms/qase-python/issues/513)). The configured `testops.api.timeout` was applied to result uploads but never to the API v1 client, so an attachment request that stalled without failing was waited on indefinitely: the batch it belonged to was never submitted, the reporter thread never finished, and `pytest` — one `pytest-xdist` worker in particular — could only be killed by the CI timeout. Every API v1 call now carries the configured timeout.
- Attachment batches are now retried on transient failures using `testops.api.retries` and `testops.api.retryBackoff`, the same policy as result uploads. Previously an attachment error was swallowed inside the upload loop, so the outer retry never saw it. After the attempts are exhausted the reporter logs the failure, drops those attachments and still submits the results — an attachment can no longer cost you the test results.

# qase-python-commons@5.1.4

## What's new
Expand Down
2 changes: 1 addition & 1 deletion qase-python-commons/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "qase-python-commons"
version = "5.1.4"
version = "5.1.5"
description = "A library for Qase TestOps and Qase Report"
readme = "README.md"
authors = [{name = "Qase Team", email = "support@qase.io"}]
Expand Down
75 changes: 59 additions & 16 deletions qase-python-commons/src/qase/commons/client/api_v1_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from qase.api_client_v1.configuration import Configuration
from .. import Logger
from .base_api_client import BaseApiClient
from ..retry import send_with_retry
from ..exceptions.reporter import ReporterException
from ..models import Attachment
from ..models.config.framework import Video, Trace
Expand Down Expand Up @@ -46,7 +47,9 @@ def __init__(self, config: QaseConfig, logger: Logger):
def get_project(self, project_code: str) -> Union[Project, None]:
try:
self.logger.log_debug(f"Getting project {project_code}")
response = ProjectsApi(self.client).get_project(code=project_code)
response = ProjectsApi(self.client).get_project(
code=project_code, _request_timeout=self.config.testops.api.timeout
)
if hasattr(response, 'result'):
self.logger.log_debug(f"Project {project_code} found: {response.result.to_json()}")
return response.result
Expand All @@ -59,7 +62,9 @@ def get_environment(self, environment: str, project_code: str) -> Union[str, Non
try:
self.logger.log_debug(f"Getting environment {environment}")
api_instance = EnvironmentsApi(self.client)
response = api_instance.get_environments(code=project_code)
response = api_instance.get_environments(
code=project_code, _request_timeout=self.config.testops.api.timeout
)
if hasattr(response, 'result') and hasattr(response.result, 'entities'):
for env in response.result.entities:
if env.slug == environment:
Expand All @@ -76,7 +81,9 @@ def get_configurations(self, project_code: str):
try:
self.logger.log_debug(f"Getting configurations for project {project_code}")
api_instance = ConfigurationsApi(self.client)
response = api_instance.get_configurations(code=project_code)
response = api_instance.get_configurations(
code=project_code, _request_timeout=self.config.testops.api.timeout
)
if hasattr(response, 'result') and hasattr(response.result, 'entities'):
return response.result.entities
return []
Expand Down Expand Up @@ -117,8 +124,9 @@ def find_or_create_configuration(self, project_code: str, config_value: Configur
# Create new group
group_create = ConfigurationGroupCreate(title=config_value.name)
group_response = ConfigurationsApi(self.client).create_configuration_group(
code=project_code,
configuration_group_create=group_create
code=project_code,
configuration_group_create=group_create,
_request_timeout=self.config.testops.api.timeout
)
group_id = group_response.result.id

Expand All @@ -129,7 +137,8 @@ def find_or_create_configuration(self, project_code: str, config_value: Configur
)
config_response = ConfigurationsApi(self.client).create_configuration(
code=project_code,
configuration_create=config_create
configuration_create=config_create,
_request_timeout=self.config.testops.api.timeout
)
config_id = config_response.result.id
return config_id
Expand All @@ -141,12 +150,16 @@ def find_or_create_configuration(self, project_code: str, config_value: Configur
def complete_run(self, project_code: str, run_id: int) -> None:
api_runs = RunsApi(self.client)
self.logger.log_debug(f"Completing run {run_id}")
res = api_runs.get_run(project_code, run_id).result
res = api_runs.get_run(
project_code, run_id, _request_timeout=self.config.testops.api.timeout
).result
if res.status == 1:
self.logger.log_debug(f"Run {run_id} already completed")
return
try:
api_runs.complete_run(project_code, run_id)
api_runs.complete_run(
project_code, run_id, _request_timeout=self.config.testops.api.timeout
)
self.logger.log(f"Test run link: {self.web}/run/{project_code}/dashboard/{run_id}", "info")
except Exception as e:
self.logger.log(f"Error at completing run {run_id}: {e}", "error")
Expand Down Expand Up @@ -240,10 +253,33 @@ def _upload_attachment(self, project_code: str, attachment: Union[Attachment, Li

# Prepare files for upload
files_for_upload = [att.get_for_upload() for att in batch]

# Upload batch
response = attach_api.upload_attachment(project_code, file=files_for_upload)


# The timeout is what keeps a stalled connection from blocking
# the reporter thread forever: without it urllib3 waits
# indefinitely, no exception is ever raised, and the retry below
# never gets a chance to run.
#
# Unlike results, an attachment carries no idempotency key, so a
# retry whose first response was lost can leave an unreferenced
# file behind. That is cheaper than the alternative: the whole
# batch of results these attachments belong to going nowhere.
response = None

def upload_batch():
nonlocal response
response = attach_api.upload_attachment(
project_code,
file=files_for_upload,
_request_timeout=self.config.testops.api.timeout
)

send_with_retry(
upload_batch,
attempts=self.config.testops.api.retries,
backoff=self.config.testops.api.retry_backoff,
logger=self.logger,
)

if response.result:
all_uploaded.extend(response.result)
self.logger.log_debug(
Expand Down Expand Up @@ -294,7 +330,8 @@ def create_test_run(self, project_code: str, title: str, description: str, plan_
try:
result = RunsApi(self.client).create_run(
code=project_code,
run_create=RunCreate(**{k: v for k, v in kwargs.items() if v is not None})
run_create=RunCreate(**{k: v for k, v in kwargs.items() if v is not None}),
_request_timeout=self.config.testops.api.timeout
)

run_id = result.result.id
Expand All @@ -311,7 +348,9 @@ def create_test_run(self, project_code: str, title: str, description: str, plan_

def check_test_run(self, project_code: str, run_id: int) -> bool:
api_runs = RunsApi(self.client)
run = api_runs.get_run(code=project_code, id=run_id)
run = api_runs.get_run(
code=project_code, id=run_id, _request_timeout=self.config.testops.api.timeout
)
if run.result.id:
return True
return False
Expand All @@ -332,7 +371,10 @@ def enable_public_report(self, project_code: str, run_id: int) -> str:
run_public = RunPublic(status=True)

# Call the API to enable public report
response = api_runs.update_run_publicity(project_code, run_id, run_public)
response = api_runs.update_run_publicity(
project_code, run_id, run_public,
_request_timeout=self.config.testops.api.timeout
)

# Extract the public URL from response
if response.result and response.result.url:
Expand Down Expand Up @@ -368,7 +410,8 @@ def update_external_link(self, project_code: str, run_id: int):

RunsApi(self.client).run_update_external_issue(
code=project_code,
runexternal_issues=run_external_issues
runexternal_issues=run_external_issues,
_request_timeout=self.config.testops.api.timeout
)

self.logger.log(f"External link updated for run {run_id}: {external_link.link}", "debug")
Expand Down
Loading
Loading