qase-python-commons 5.1.2__tar.gz → 5.1.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. {qase_python_commons-5.1.2 → qase_python_commons-5.1.4}/PKG-INFO +46 -1
  2. {qase_python_commons-5.1.2 → qase_python_commons-5.1.4}/README.md +45 -0
  3. {qase_python_commons-5.1.2 → qase_python_commons-5.1.4}/pyproject.toml +1 -1
  4. {qase_python_commons-5.1.2 → qase_python_commons-5.1.4}/src/qase/commons/client/api_v1_client.py +3 -0
  5. {qase_python_commons-5.1.2 → qase_python_commons-5.1.4}/src/qase/commons/client/api_v2_client.py +8 -1
  6. {qase_python_commons-5.1.2 → qase_python_commons-5.1.4}/src/qase/commons/config.py +23 -0
  7. qase_python_commons-5.1.4/src/qase/commons/models/config/api.py +39 -0
  8. {qase_python_commons-5.1.2 → qase_python_commons-5.1.4}/src/qase/commons/models/config/batch.py +2 -2
  9. {qase_python_commons-5.1.2 → qase_python_commons-5.1.4}/src/qase/commons/reporters/testops.py +35 -8
  10. {qase_python_commons-5.1.2 → qase_python_commons-5.1.4}/src/qase/commons/reporters/testops_multi.py +39 -8
  11. qase_python_commons-5.1.4/src/qase/commons/retry.py +94 -0
  12. {qase_python_commons-5.1.2 → qase_python_commons-5.1.4}/src/qase_python_commons.egg-info/PKG-INFO +46 -1
  13. {qase_python_commons-5.1.2 → qase_python_commons-5.1.4}/src/qase_python_commons.egg-info/SOURCES.txt +1 -0
  14. qase_python_commons-5.1.2/src/qase/commons/models/config/api.py +0 -15
  15. {qase_python_commons-5.1.2 → qase_python_commons-5.1.4}/setup.cfg +0 -0
  16. {qase_python_commons-5.1.2 → qase_python_commons-5.1.4}/src/qase/__init__.py +0 -0
  17. {qase_python_commons-5.1.2 → qase_python_commons-5.1.4}/src/qase/commons/__init__.py +0 -0
  18. {qase_python_commons-5.1.2 → qase_python_commons-5.1.4}/src/qase/commons/client/base_api_client.py +0 -0
  19. {qase_python_commons-5.1.2 → qase_python_commons-5.1.4}/src/qase/commons/exceptions/reporter.py +0 -0
  20. {qase_python_commons-5.1.2 → qase_python_commons-5.1.4}/src/qase/commons/loader.py +0 -0
  21. {qase_python_commons-5.1.2 → qase_python_commons-5.1.4}/src/qase/commons/logger.py +0 -0
  22. {qase_python_commons-5.1.2 → qase_python_commons-5.1.4}/src/qase/commons/models/__init__.py +0 -0
  23. {qase_python_commons-5.1.2 → qase_python_commons-5.1.4}/src/qase/commons/models/attachment.py +0 -0
  24. {qase_python_commons-5.1.2 → qase_python_commons-5.1.4}/src/qase/commons/models/basemodel.py +0 -0
  25. {qase_python_commons-5.1.2 → qase_python_commons-5.1.4}/src/qase/commons/models/config/connection.py +0 -0
  26. {qase_python_commons-5.1.2 → qase_python_commons-5.1.4}/src/qase/commons/models/config/framework.py +0 -0
  27. {qase_python_commons-5.1.2 → qase_python_commons-5.1.4}/src/qase/commons/models/config/plan.py +0 -0
  28. {qase_python_commons-5.1.2 → qase_python_commons-5.1.4}/src/qase/commons/models/config/qaseconfig.py +0 -0
  29. {qase_python_commons-5.1.2 → qase_python_commons-5.1.4}/src/qase/commons/models/config/report.py +0 -0
  30. {qase_python_commons-5.1.2 → qase_python_commons-5.1.4}/src/qase/commons/models/config/run.py +0 -0
  31. {qase_python_commons-5.1.2 → qase_python_commons-5.1.4}/src/qase/commons/models/config/testops.py +0 -0
  32. {qase_python_commons-5.1.2 → qase_python_commons-5.1.4}/src/qase/commons/models/external_link.py +0 -0
  33. {qase_python_commons-5.1.2 → qase_python_commons-5.1.4}/src/qase/commons/models/relation.py +0 -0
  34. {qase_python_commons-5.1.2 → qase_python_commons-5.1.4}/src/qase/commons/models/result.py +0 -0
  35. {qase_python_commons-5.1.2 → qase_python_commons-5.1.4}/src/qase/commons/models/run.py +0 -0
  36. {qase_python_commons-5.1.2 → qase_python_commons-5.1.4}/src/qase/commons/models/runtime.py +0 -0
  37. {qase_python_commons-5.1.2 → qase_python_commons-5.1.4}/src/qase/commons/models/step.py +0 -0
  38. {qase_python_commons-5.1.2 → qase_python_commons-5.1.4}/src/qase/commons/profilers/__init__.py +0 -0
  39. {qase_python_commons-5.1.2 → qase_python_commons-5.1.4}/src/qase/commons/profilers/db.py +0 -0
  40. {qase_python_commons-5.1.2 → qase_python_commons-5.1.4}/src/qase/commons/profilers/network.py +0 -0
  41. {qase_python_commons-5.1.2 → qase_python_commons-5.1.4}/src/qase/commons/profilers/sleep.py +0 -0
  42. {qase_python_commons-5.1.2 → qase_python_commons-5.1.4}/src/qase/commons/reporters/__init__.py +0 -0
  43. {qase_python_commons-5.1.2 → qase_python_commons-5.1.4}/src/qase/commons/reporters/core.py +0 -0
  44. {qase_python_commons-5.1.2 → qase_python_commons-5.1.4}/src/qase/commons/reporters/report.py +0 -0
  45. {qase_python_commons-5.1.2 → qase_python_commons-5.1.4}/src/qase/commons/status_mapping/__init__.py +0 -0
  46. {qase_python_commons-5.1.2 → qase_python_commons-5.1.4}/src/qase/commons/status_mapping/status_mapping.py +0 -0
  47. {qase_python_commons-5.1.2 → qase_python_commons-5.1.4}/src/qase/commons/util/__init__.py +0 -0
  48. {qase_python_commons-5.1.2 → qase_python_commons-5.1.4}/src/qase/commons/util/host_data.py +0 -0
  49. {qase_python_commons-5.1.2 → qase_python_commons-5.1.4}/src/qase/commons/util/token_masker.py +0 -0
  50. {qase_python_commons-5.1.2 → qase_python_commons-5.1.4}/src/qase/commons/utils.py +0 -0
  51. {qase_python_commons-5.1.2 → qase_python_commons-5.1.4}/src/qase/commons/validators/base.py +0 -0
  52. {qase_python_commons-5.1.2 → qase_python_commons-5.1.4}/src/qase_python_commons.egg-info/dependency_links.txt +0 -0
  53. {qase_python_commons-5.1.2 → qase_python_commons-5.1.4}/src/qase_python_commons.egg-info/requires.txt +0 -0
  54. {qase_python_commons-5.1.2 → qase_python_commons-5.1.4}/src/qase_python_commons.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: qase-python-commons
3
- Version: 5.1.2
3
+ Version: 5.1.4
4
4
  Summary: A library for Qase TestOps and Qase Report
5
5
  Author-email: Qase Team <support@qase.io>
6
6
  Project-URL: Homepage, https://github.com/qase-tms/qase-python/tree/main/qase-python-commons
@@ -65,6 +65,7 @@ Core library for all Qase Python reporters. Contains the complete configuration
65
65
  - [Status Filtering](#status-filtering)
66
66
  - [External Links](#external-links)
67
67
  - [Test Run Configurations](#test-run-configurations)
68
+ - [Upload Reliability](#upload-reliability)
68
69
 
69
70
  ---
70
71
 
@@ -137,6 +138,9 @@ The reporter mode is set via the `mode` option:
137
138
  |-------------|-------------|---------------------|---------|----------|
138
139
  | API token | `testops.api.token` | `QASE_TESTOPS_API_TOKEN` | — | Yes* |
139
140
  | API host | `testops.api.host` | `QASE_TESTOPS_API_HOST` | `qase.io` | No |
141
+ | Request timeout, seconds | `testops.api.timeout` | `QASE_TESTOPS_API_TIMEOUT` | `30` | No |
142
+ | Upload attempts | `testops.api.retries` | `QASE_TESTOPS_API_RETRIES` | `3` | No |
143
+ | Retry backoff base, seconds | `testops.api.retryBackoff` | `QASE_TESTOPS_API_RETRY_BACKOFF` | `2` | No |
140
144
  | Project code | `testops.project` | `QASE_TESTOPS_PROJECT` | — | Yes* |
141
145
  | Test run ID | `testops.run.id` | `QASE_TESTOPS_RUN_ID` | — | No |
142
146
  | Test run title | `testops.run.title` | `QASE_TESTOPS_RUN_TITLE` | `Automated run <date>` | No |
@@ -296,6 +300,9 @@ export QASE_TESTOPS_API_TOKEN="<token>"
296
300
  export QASE_TESTOPS_PROJECT="DEMO"
297
301
  export QASE_TESTOPS_RUN_TITLE="Automated Run"
298
302
  export QASE_TESTOPS_RUN_COMPLETE="true"
303
+ export QASE_TESTOPS_API_TIMEOUT="30"
304
+ export QASE_TESTOPS_API_RETRIES="3"
305
+ export QASE_TESTOPS_API_RETRY_BACKOFF="2"
299
306
 
300
307
  # Pytest
301
308
  export QASE_PYTEST_CAPTURE_LOGS="true"
@@ -409,6 +416,44 @@ Creates or finds configurations in Qase TestOps:
409
416
  }
410
417
  ```
411
418
 
419
+ ### Upload Reliability
420
+
421
+ Results are uploaded in batches from background threads. A batch that fails on
422
+ a transient error is retried rather than dropped.
423
+
424
+ | Setting | Meaning |
425
+ |---------|---------|
426
+ | `testops.api.timeout` | Per-request timeout in seconds. Without it a stalled connection blocks the session at teardown. |
427
+ | `testops.api.retries` | Total attempts per batch, not retries on top of the first. `3` means three tries; `0` sends once and never retries. |
428
+ | `testops.api.retryBackoff` | Base of the exponential delay: attempt *n* waits `retryBackoff ** n` seconds. |
429
+
430
+ Retried: connection resets, timeouts and other transport failures, plus HTTP
431
+ 408, 429 and 5xx. Not retried: 400, 401, 403, 404, 413, 422 and 507 — a second
432
+ attempt fails identically and only adds load.
433
+
434
+ When a 429 carries a `Retry-After` header, that value replaces the computed
435
+ backoff. Qase sends roughly 60 seconds, so a run that hits the rate limit takes
436
+ longer to finish rather than losing the batch.
437
+
438
+ **If a batch cannot be delivered after all attempts**, the reporter logs an
439
+ error naming how many results were lost and **does not mark the run complete**.
440
+ An open run is the signal that its data is incomplete; a completed run over
441
+ partial results would look trustworthy and not be. In `testops_multi` mode this
442
+ is per project — one project's failure does not stop the others completing.
443
+
444
+ ```json
445
+ {
446
+ "testops": {
447
+ "api": {
448
+ "token": "<token>",
449
+ "timeout": 30,
450
+ "retries": 3,
451
+ "retryBackoff": 2
452
+ }
453
+ }
454
+ }
455
+ ```
456
+
412
457
  ---
413
458
 
414
459
  ## Requirements
@@ -30,6 +30,7 @@ Core library for all Qase Python reporters. Contains the complete configuration
30
30
  - [Status Filtering](#status-filtering)
31
31
  - [External Links](#external-links)
32
32
  - [Test Run Configurations](#test-run-configurations)
33
+ - [Upload Reliability](#upload-reliability)
33
34
 
34
35
  ---
35
36
 
@@ -102,6 +103,9 @@ The reporter mode is set via the `mode` option:
102
103
  |-------------|-------------|---------------------|---------|----------|
103
104
  | API token | `testops.api.token` | `QASE_TESTOPS_API_TOKEN` | — | Yes* |
104
105
  | API host | `testops.api.host` | `QASE_TESTOPS_API_HOST` | `qase.io` | No |
106
+ | Request timeout, seconds | `testops.api.timeout` | `QASE_TESTOPS_API_TIMEOUT` | `30` | No |
107
+ | Upload attempts | `testops.api.retries` | `QASE_TESTOPS_API_RETRIES` | `3` | No |
108
+ | Retry backoff base, seconds | `testops.api.retryBackoff` | `QASE_TESTOPS_API_RETRY_BACKOFF` | `2` | No |
105
109
  | Project code | `testops.project` | `QASE_TESTOPS_PROJECT` | — | Yes* |
106
110
  | Test run ID | `testops.run.id` | `QASE_TESTOPS_RUN_ID` | — | No |
107
111
  | Test run title | `testops.run.title` | `QASE_TESTOPS_RUN_TITLE` | `Automated run <date>` | No |
@@ -261,6 +265,9 @@ export QASE_TESTOPS_API_TOKEN="<token>"
261
265
  export QASE_TESTOPS_PROJECT="DEMO"
262
266
  export QASE_TESTOPS_RUN_TITLE="Automated Run"
263
267
  export QASE_TESTOPS_RUN_COMPLETE="true"
268
+ export QASE_TESTOPS_API_TIMEOUT="30"
269
+ export QASE_TESTOPS_API_RETRIES="3"
270
+ export QASE_TESTOPS_API_RETRY_BACKOFF="2"
264
271
 
265
272
  # Pytest
266
273
  export QASE_PYTEST_CAPTURE_LOGS="true"
@@ -374,6 +381,44 @@ Creates or finds configurations in Qase TestOps:
374
381
  }
375
382
  ```
376
383
 
384
+ ### Upload Reliability
385
+
386
+ Results are uploaded in batches from background threads. A batch that fails on
387
+ a transient error is retried rather than dropped.
388
+
389
+ | Setting | Meaning |
390
+ |---------|---------|
391
+ | `testops.api.timeout` | Per-request timeout in seconds. Without it a stalled connection blocks the session at teardown. |
392
+ | `testops.api.retries` | Total attempts per batch, not retries on top of the first. `3` means three tries; `0` sends once and never retries. |
393
+ | `testops.api.retryBackoff` | Base of the exponential delay: attempt *n* waits `retryBackoff ** n` seconds. |
394
+
395
+ Retried: connection resets, timeouts and other transport failures, plus HTTP
396
+ 408, 429 and 5xx. Not retried: 400, 401, 403, 404, 413, 422 and 507 — a second
397
+ attempt fails identically and only adds load.
398
+
399
+ When a 429 carries a `Retry-After` header, that value replaces the computed
400
+ backoff. Qase sends roughly 60 seconds, so a run that hits the rate limit takes
401
+ longer to finish rather than losing the batch.
402
+
403
+ **If a batch cannot be delivered after all attempts**, the reporter logs an
404
+ error naming how many results were lost and **does not mark the run complete**.
405
+ An open run is the signal that its data is incomplete; a completed run over
406
+ partial results would look trustworthy and not be. In `testops_multi` mode this
407
+ is per project — one project's failure does not stop the others completing.
408
+
409
+ ```json
410
+ {
411
+ "testops": {
412
+ "api": {
413
+ "token": "<token>",
414
+ "timeout": 30,
415
+ "retries": 3,
416
+ "retryBackoff": 2
417
+ }
418
+ }
419
+ }
420
+ ```
421
+
377
422
  ---
378
423
 
379
424
  ## Requirements
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "qase-python-commons"
7
- version = "5.1.2"
7
+ version = "5.1.4"
8
8
  description = "A library for Qase TestOps and Qase Report"
9
9
  readme = "README.md"
10
10
  authors = [{name = "Qase Team", email = "support@qase.io"}]
@@ -26,6 +26,9 @@ class ApiV1Client(BaseApiClient):
26
26
  configuration = Configuration()
27
27
  configuration.api_key['TokenAuth'] = self.config.testops.api.token
28
28
  configuration.ssl_ca_cert = certifi.where()
29
+ # See the note in ApiV2Client: urllib3's default retry policy is
30
+ # pinned off so it cannot multiply with the application-level one.
31
+ configuration.retries = 0
29
32
  host = self.config.testops.api.host
30
33
  if host == 'qase.io':
31
34
  configuration.host = f'https://api.{host}/v1'
@@ -36,6 +36,11 @@ class ApiV2Client(ApiV1Client):
36
36
  configuration = Configuration()
37
37
  configuration.api_key['TokenAuth'] = self.config.testops.api.token
38
38
  configuration.ssl_ca_cert = certifi.where()
39
+ # Pin urllib3's own retry policy off: the reporter retries at the
40
+ # application level, and leaving this at None lets urllib3's
41
+ # defaults multiply with ours -- a 3-attempt policy would issue
42
+ # 9 requests, the opposite of what is wanted on a 429.
43
+ configuration.retries = 0
39
44
  host = self.config.testops.api.host
40
45
  if host == 'qase.io':
41
46
  configuration.host = f'https://api.{host}/v2'
@@ -131,7 +136,8 @@ class ApiV2Client(ApiV1Client):
131
136
  run_id_int = int(run_id) if isinstance(run_id, str) else run_id
132
137
  self.logger.log_debug(f"Sending results for run {run_id_int}: {results_to_send}")
133
138
  api_results.create_results_v2(project_code, run_id_int,
134
- create_results_request_v2=CreateResultsRequestV2(results=results_to_send))
139
+ create_results_request_v2=CreateResultsRequestV2(results=results_to_send),
140
+ _request_timeout=self.config.testops.api.timeout)
135
141
  self.logger.log_debug(f"Results for run {run_id_int} sent successfully")
136
142
 
137
143
  def _prepare_result(self, project_code: str, result: Result) -> ResultCreate:
@@ -158,6 +164,7 @@ class ApiV2Client(ApiV1Client):
158
164
  result.params[key] = "empty"
159
165
 
160
166
  result_model_v2 = ResultCreate(
167
+ id=result.id,
161
168
  title=result.get_title(),
162
169
  signature=result.signature,
163
170
  testops_ids=result.get_testops_ids(),
@@ -109,6 +109,20 @@ class ConfigManager:
109
109
  self.config.testops.api.set_token(
110
110
  api.get("token"))
111
111
 
112
+ if api.get("timeout"):
113
+ self.config.testops.api.set_timeout(
114
+ api.get("timeout"))
115
+
116
+ # `is not None`, not truthiness: 0 is a valid value
117
+ # for both, meaning "no retries" / "no backoff".
118
+ if api.get("retries") is not None:
119
+ self.config.testops.api.set_retries(
120
+ api.get("retries"))
121
+
122
+ if api.get("retryBackoff") is not None:
123
+ self.config.testops.api.set_retry_backoff(
124
+ api.get("retryBackoff"))
125
+
112
126
  if testops.get("project"):
113
127
  self.config.testops.set_project(
114
128
  testops.get("project"))
@@ -288,6 +302,15 @@ class ConfigManager:
288
302
  if key == 'QASE_TESTOPS_API_TOKEN':
289
303
  self.config.testops.api.set_token(value)
290
304
 
305
+ if key == 'QASE_TESTOPS_API_TIMEOUT':
306
+ self.config.testops.api.set_timeout(value)
307
+
308
+ if key == 'QASE_TESTOPS_API_RETRIES':
309
+ self.config.testops.api.set_retries(value)
310
+
311
+ if key == 'QASE_TESTOPS_API_RETRY_BACKOFF':
312
+ self.config.testops.api.set_retry_backoff(value)
313
+
291
314
  if key == 'QASE_TESTOPS_PROJECT':
292
315
  self.config.testops.set_project(value)
293
316
 
@@ -0,0 +1,39 @@
1
+ from ..basemodel import BaseModel
2
+
3
+
4
+ class ApiConfig(BaseModel):
5
+ token: str = None
6
+ host: str = None
7
+ timeout: int = None
8
+ retries: int = None
9
+ retry_backoff: int = None
10
+
11
+ def __init__(self):
12
+ self.host = "qase.io"
13
+ self.timeout = 30
14
+ self.retries = 3
15
+ self.retry_backoff = 2
16
+
17
+ def set_token(self, token: str):
18
+ self.token = token
19
+
20
+ def set_host(self, host: str):
21
+ self.host = host
22
+
23
+ def set_timeout(self, timeout: int):
24
+ timeout = int(timeout)
25
+ if timeout <= 0:
26
+ raise ValueError("API timeout should be greater than 0")
27
+ self.timeout = timeout
28
+
29
+ def set_retries(self, retries: int):
30
+ retries = int(retries)
31
+ if retries < 0:
32
+ raise ValueError("API retries should be 0 or greater")
33
+ self.retries = retries
34
+
35
+ def set_retry_backoff(self, retry_backoff: int):
36
+ retry_backoff = int(retry_backoff)
37
+ if retry_backoff < 0:
38
+ raise ValueError("API retry backoff should be 0 or greater")
39
+ self.retry_backoff = retry_backoff
@@ -8,6 +8,6 @@ class BatchConfig(BaseModel):
8
8
  self.size = 200
9
9
 
10
10
  def set_size(self, size: int):
11
- if size > 2000 or size == 0:
12
- raise ValueError("Batch size should be greater than 0 and less than 2000")
11
+ if size > 200 or size == 0:
12
+ raise ValueError("Batch size should be greater than 0 and no greater than 200")
13
13
  self.size = size
@@ -8,6 +8,7 @@ from .. import Logger, ReporterException
8
8
  from ..client.base_api_client import BaseApiClient
9
9
  from ..models import Result
10
10
  from ..models.config.qaseconfig import QaseConfig
11
+ from ..retry import send_with_retry
11
12
 
12
13
  DEFAULT_BATCH_SIZE = 200
13
14
  DEFAULT_THREAD_COUNT = 4
@@ -32,10 +33,12 @@ class QaseTestOps:
32
33
  self.complete_after_run = self.config.testops.run.complete
33
34
  self.environment = None
34
35
 
35
- self.batch_size = min(2000, max(1, int(self.config.testops.batch.size or DEFAULT_BATCH_SIZE)))
36
+ self.batch_size = min(200, max(1, int(self.config.testops.batch.size or DEFAULT_BATCH_SIZE)))
36
37
  self.send_semaphore = threading.Semaphore(DEFAULT_THREAD_COUNT) # Semaphore to limit concurrent sends
37
38
  self.lock = threading.Lock()
38
39
  self.count_running_threads = 0
40
+ # Results in batches that could not be delivered after all retries.
41
+ self.lost_results = 0
39
42
 
40
43
  environment = self.config.environment
41
44
  if environment:
@@ -70,15 +73,28 @@ class QaseTestOps:
70
73
 
71
74
  def _send_results_threaded(self, results):
72
75
  try:
73
- self.client.send_results(self.project_code, self.run_id, results)
76
+ send_with_retry(
77
+ lambda: self.client.send_results(self.project_code, self.run_id, results),
78
+ attempts=self.config.testops.api.retries,
79
+ backoff=self.config.testops.api.retry_backoff,
80
+ logger=self.logger,
81
+ )
74
82
  with self.lock:
75
83
  self.processed.extend(results)
76
84
  except Exception as e:
85
+ # Account for the batch here rather than re-raising. The raise only
86
+ # ever surfaced as a PytestUnhandledThreadExceptionWarning that
87
+ # nothing acted on, which is how the loss stayed invisible.
77
88
  with self.lock:
78
- self.logger.log(f"Error at sending results for run {self.run_id}: {e}", "error")
79
- raise # Re-raise the exception to be caught by the thread handler
89
+ self.lost_results += len(results)
90
+ self.logger.log(
91
+ f"Failed to send {len(results)} results for run {self.run_id} "
92
+ f"after {self.config.testops.api.retries} attempt(s): {e}",
93
+ "error",
94
+ )
80
95
  finally:
81
- self.count_running_threads -= 1
96
+ with self.lock:
97
+ self.count_running_threads -= 1
82
98
  self.send_semaphore.release() # Release semaphore whether success or exception
83
99
 
84
100
  def _send_results(self) -> None:
@@ -101,7 +117,8 @@ class QaseTestOps:
101
117
  if results_to_send:
102
118
  # Acquire semaphore before starting the send operation
103
119
  self.send_semaphore.acquire()
104
- self.count_running_threads += 1
120
+ with self.lock:
121
+ self.count_running_threads += 1
105
122
 
106
123
  # Start a new thread for sending results
107
124
  send_thread = threading.Thread(target=self._send_results_threaded, args=(results_to_send,))
@@ -136,7 +153,17 @@ class QaseTestOps:
136
153
 
137
154
  while self.count_running_threads > 0:
138
155
  time.sleep(DEFAULT_THREAD_POLL_INTERVAL)
139
-
156
+
157
+ if self.lost_results > 0:
158
+ # Leaving the run open is the signal. A run marked complete over
159
+ # partial data looks trustworthy and is not.
160
+ self.logger.log(
161
+ f"{self.lost_results} result(s) were not delivered to Qase. "
162
+ f"The run will not be marked complete so the gap stays visible.",
163
+ "error",
164
+ )
165
+ return
166
+
140
167
  if self.complete_after_run:
141
168
  self.logger.log_debug("Completing run")
142
169
  self.client.complete_run(self.project_code, self.run_id)
@@ -158,7 +185,7 @@ class QaseTestOps:
158
185
  if len(self.results) > 0:
159
186
  self._send_results()
160
187
  while self.count_running_threads > 0:
161
- pass
188
+ time.sleep(DEFAULT_THREAD_POLL_INTERVAL)
162
189
  self.logger.log_debug("Worker completed")
163
190
 
164
191
  def add_result(self, result: Result) -> None:
@@ -1,4 +1,5 @@
1
1
  import threading
2
+ import time
2
3
  import urllib.parse
3
4
  import copy
4
5
 
@@ -8,9 +9,11 @@ from .. import Logger, ReporterException
8
9
  from ..client.base_api_client import BaseApiClient
9
10
  from ..models import Result
10
11
  from ..models.config.qaseconfig import QaseConfig
12
+ from ..retry import send_with_retry
11
13
 
12
14
  DEFAULT_BATCH_SIZE = 200
13
15
  DEFAULT_THREAD_COUNT = 4
16
+ DEFAULT_THREAD_POLL_INTERVAL = 0.05
14
17
 
15
18
 
16
19
  class QaseTestOpsMulti:
@@ -22,7 +25,7 @@ class QaseTestOpsMulti:
22
25
  self.client = client
23
26
 
24
27
  self.multi_config = config.testops_multi
25
- self.batch_size = min(2000, max(1, int(self.config.testops.batch.size or DEFAULT_BATCH_SIZE)))
28
+ self.batch_size = min(200, max(1, int(self.config.testops.batch.size or DEFAULT_BATCH_SIZE)))
26
29
  self.send_semaphore = threading.Semaphore(DEFAULT_THREAD_COUNT)
27
30
  self.lock = threading.Lock()
28
31
  self.count_running_threads = 0
@@ -44,6 +47,9 @@ class QaseTestOpsMulti:
44
47
  self.project_results: Dict[str, List[Result]] = {project.code: [] for project in self.multi_config.projects}
45
48
  self.project_runs: Dict[str, int] = {} # Store run_id as int
46
49
  self.processed: Dict[str, List[Result]] = {project.code: [] for project in self.multi_config.projects}
50
+ # Results in batches that could not be delivered after all retries,
51
+ # counted per project so one project's loss does not hide another's.
52
+ self.lost_results: Dict[str, int] = {project.code: 0 for project in self.multi_config.projects}
47
53
 
48
54
  # Initialize environment for each project
49
55
  for project_code, project_data in self.project_configs.items():
@@ -101,15 +107,28 @@ class QaseTestOpsMulti:
101
107
  try:
102
108
  # Convert run_id to str for send_results (it will convert to int internally for API)
103
109
  run_id_str = str(run_id) if isinstance(run_id, int) else run_id
104
- self.client.send_results(project_code, run_id_str, results)
110
+ send_with_retry(
111
+ lambda: self.client.send_results(project_code, run_id_str, results),
112
+ attempts=self.config.testops.api.retries,
113
+ backoff=self.config.testops.api.retry_backoff,
114
+ logger=self.logger,
115
+ )
105
116
  with self.lock:
106
117
  self.processed[project_code].extend(results)
107
118
  except Exception as e:
119
+ # Account for the batch here rather than re-raising. The raise only
120
+ # ever surfaced as a PytestUnhandledThreadExceptionWarning that
121
+ # nothing acted on, which is how the loss stayed invisible.
108
122
  with self.lock:
109
- self.logger.log(f"Error at sending results for project {project_code}, run {run_id}: {e}", "error")
110
- raise
123
+ self.lost_results[project_code] = self.lost_results.get(project_code, 0) + len(results)
124
+ self.logger.log(
125
+ f"Failed to send {len(results)} results for project {project_code}, "
126
+ f"run {run_id} after {self.config.testops.api.retries} attempt(s): {e}",
127
+ "error",
128
+ )
111
129
  finally:
112
- self.count_running_threads -= 1
130
+ with self.lock:
131
+ self.count_running_threads -= 1
113
132
  self.send_semaphore.release()
114
133
 
115
134
  def _send_results_for_project(self, project_code: str) -> None:
@@ -141,7 +160,8 @@ class QaseTestOpsMulti:
141
160
  if results_to_send:
142
161
  # Acquire semaphore before starting the send operation
143
162
  self.send_semaphore.acquire()
144
- self.count_running_threads += 1
163
+ with self.lock:
164
+ self.count_running_threads += 1
145
165
 
146
166
  # Start a new thread for sending results
147
167
  # run_id is stored as int, convert to str for thread (will be converted back to int in send_results)
@@ -230,10 +250,21 @@ class QaseTestOpsMulti:
230
250
 
231
251
  # Wait for all send operations to complete
232
252
  while self.count_running_threads > 0:
233
- pass
253
+ time.sleep(DEFAULT_THREAD_POLL_INTERVAL)
234
254
 
235
255
  # Complete all test runs
236
256
  for project_code, project_data in self.project_configs.items():
257
+ if self.lost_results.get(project_code, 0) > 0:
258
+ # `continue`, not `return`: one project's loss must not stop
259
+ # the others completing. Leaving this run open is the signal.
260
+ self.logger.log(
261
+ f"{self.lost_results[project_code]} result(s) were not delivered for "
262
+ f"project {project_code}. Its run will not be marked complete so the "
263
+ f"gap stays visible.",
264
+ "error",
265
+ )
266
+ continue
267
+
237
268
  if project_data.get('complete_after_run'):
238
269
  run_id = self.project_runs.get(project_code)
239
270
  if run_id:
@@ -258,7 +289,7 @@ class QaseTestOpsMulti:
258
289
  if any(len(results) > 0 for results in self.project_results.values()):
259
290
  self._send_results()
260
291
  while self.count_running_threads > 0:
261
- pass
292
+ time.sleep(DEFAULT_THREAD_POLL_INTERVAL)
262
293
  self.logger.log_debug("Worker completed")
263
294
 
264
295
  def add_result(self, result: Result) -> None:
@@ -0,0 +1,94 @@
1
+ """Bounded retry for result uploads.
2
+
3
+ Retries transport failures and the HTTP statuses that can succeed on a second
4
+ attempt. Never retries a status that would be rejected identically -- an
5
+ oversized batch, an invalid payload, an expired token -- where retrying only
6
+ delays the error and adds load.
7
+
8
+ Safe only because ResultCreate.id is populated: a request that was accepted
9
+ but whose response was lost is indistinguishable from one that never arrived,
10
+ and the idempotency key is what stops the retry creating duplicates.
11
+ """
12
+
13
+ import time
14
+ from typing import Callable, Optional
15
+
16
+ RETRYABLE_STATUSES = frozenset({408, 429, 500, 502, 503, 504})
17
+
18
+ _TRANSPORT_ERRORS = (ConnectionError, OSError, TimeoutError)
19
+
20
+
21
+ def _urllib3_errors():
22
+ try:
23
+ from urllib3.exceptions import HTTPError
24
+
25
+ return (HTTPError,)
26
+ except ImportError: # pragma: no cover - urllib3 is a hard dependency
27
+ return ()
28
+
29
+
30
+ def is_retryable(exc: BaseException) -> bool:
31
+ """True when another attempt could plausibly succeed."""
32
+ status = getattr(exc, "status", None)
33
+ if status is not None:
34
+ try:
35
+ return int(status) in RETRYABLE_STATUSES
36
+ except (TypeError, ValueError):
37
+ return False
38
+ return isinstance(exc, _TRANSPORT_ERRORS + _urllib3_errors())
39
+
40
+
41
+ def retry_after_seconds(exc: BaseException) -> Optional[float]:
42
+ """Seconds requested by a Retry-After header, when there is one.
43
+
44
+ Qase answers 429 with roughly 60 seconds, which no short backoff ladder
45
+ survives, so honouring the header matters more than the ladder itself.
46
+ """
47
+ headers = getattr(exc, "headers", None)
48
+ if not headers:
49
+ return None
50
+ try:
51
+ value = headers.get("Retry-After")
52
+ except AttributeError:
53
+ return None
54
+ if value is None:
55
+ return None
56
+ try:
57
+ return float(value)
58
+ except (TypeError, ValueError):
59
+ # The RFC also allows an HTTP-date. We do not parse it: falling back to
60
+ # the computed backoff is safer than guessing at a clock skew.
61
+ return None
62
+
63
+
64
+ def send_with_retry(
65
+ send: Callable[[], None],
66
+ attempts: int,
67
+ backoff: int,
68
+ sleep: Callable[[float], None] = time.sleep,
69
+ logger=None,
70
+ ) -> None:
71
+ """Call send(), retrying retryable failures. Re-raises the last exception.
72
+
73
+ `attempts` is the total number of tries, not the number of retries on top
74
+ of the first: a configured `retries: 3` means three attempts in all, and
75
+ `retries: 0` still sends once but never retries.
76
+ """
77
+ attempts = max(1, attempts)
78
+ for attempt in range(1, attempts + 1):
79
+ try:
80
+ send()
81
+ return
82
+ except Exception as exc:
83
+ if attempt == attempts or not is_retryable(exc):
84
+ raise
85
+ delay = retry_after_seconds(exc)
86
+ if delay is None:
87
+ delay = backoff ** attempt
88
+ if logger:
89
+ logger.log(
90
+ f"Upload attempt {attempt}/{attempts} failed ({exc}); "
91
+ f"retrying in {delay}s",
92
+ "warning",
93
+ )
94
+ sleep(delay)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: qase-python-commons
3
- Version: 5.1.2
3
+ Version: 5.1.4
4
4
  Summary: A library for Qase TestOps and Qase Report
5
5
  Author-email: Qase Team <support@qase.io>
6
6
  Project-URL: Homepage, https://github.com/qase-tms/qase-python/tree/main/qase-python-commons
@@ -65,6 +65,7 @@ Core library for all Qase Python reporters. Contains the complete configuration
65
65
  - [Status Filtering](#status-filtering)
66
66
  - [External Links](#external-links)
67
67
  - [Test Run Configurations](#test-run-configurations)
68
+ - [Upload Reliability](#upload-reliability)
68
69
 
69
70
  ---
70
71
 
@@ -137,6 +138,9 @@ The reporter mode is set via the `mode` option:
137
138
  |-------------|-------------|---------------------|---------|----------|
138
139
  | API token | `testops.api.token` | `QASE_TESTOPS_API_TOKEN` | — | Yes* |
139
140
  | API host | `testops.api.host` | `QASE_TESTOPS_API_HOST` | `qase.io` | No |
141
+ | Request timeout, seconds | `testops.api.timeout` | `QASE_TESTOPS_API_TIMEOUT` | `30` | No |
142
+ | Upload attempts | `testops.api.retries` | `QASE_TESTOPS_API_RETRIES` | `3` | No |
143
+ | Retry backoff base, seconds | `testops.api.retryBackoff` | `QASE_TESTOPS_API_RETRY_BACKOFF` | `2` | No |
140
144
  | Project code | `testops.project` | `QASE_TESTOPS_PROJECT` | — | Yes* |
141
145
  | Test run ID | `testops.run.id` | `QASE_TESTOPS_RUN_ID` | — | No |
142
146
  | Test run title | `testops.run.title` | `QASE_TESTOPS_RUN_TITLE` | `Automated run <date>` | No |
@@ -296,6 +300,9 @@ export QASE_TESTOPS_API_TOKEN="<token>"
296
300
  export QASE_TESTOPS_PROJECT="DEMO"
297
301
  export QASE_TESTOPS_RUN_TITLE="Automated Run"
298
302
  export QASE_TESTOPS_RUN_COMPLETE="true"
303
+ export QASE_TESTOPS_API_TIMEOUT="30"
304
+ export QASE_TESTOPS_API_RETRIES="3"
305
+ export QASE_TESTOPS_API_RETRY_BACKOFF="2"
299
306
 
300
307
  # Pytest
301
308
  export QASE_PYTEST_CAPTURE_LOGS="true"
@@ -409,6 +416,44 @@ Creates or finds configurations in Qase TestOps:
409
416
  }
410
417
  ```
411
418
 
419
+ ### Upload Reliability
420
+
421
+ Results are uploaded in batches from background threads. A batch that fails on
422
+ a transient error is retried rather than dropped.
423
+
424
+ | Setting | Meaning |
425
+ |---------|---------|
426
+ | `testops.api.timeout` | Per-request timeout in seconds. Without it a stalled connection blocks the session at teardown. |
427
+ | `testops.api.retries` | Total attempts per batch, not retries on top of the first. `3` means three tries; `0` sends once and never retries. |
428
+ | `testops.api.retryBackoff` | Base of the exponential delay: attempt *n* waits `retryBackoff ** n` seconds. |
429
+
430
+ Retried: connection resets, timeouts and other transport failures, plus HTTP
431
+ 408, 429 and 5xx. Not retried: 400, 401, 403, 404, 413, 422 and 507 — a second
432
+ attempt fails identically and only adds load.
433
+
434
+ When a 429 carries a `Retry-After` header, that value replaces the computed
435
+ backoff. Qase sends roughly 60 seconds, so a run that hits the rate limit takes
436
+ longer to finish rather than losing the batch.
437
+
438
+ **If a batch cannot be delivered after all attempts**, the reporter logs an
439
+ error naming how many results were lost and **does not mark the run complete**.
440
+ An open run is the signal that its data is incomplete; a completed run over
441
+ partial results would look trustworthy and not be. In `testops_multi` mode this
442
+ is per project — one project's failure does not stop the others completing.
443
+
444
+ ```json
445
+ {
446
+ "testops": {
447
+ "api": {
448
+ "token": "<token>",
449
+ "timeout": 30,
450
+ "retries": 3,
451
+ "retryBackoff": 2
452
+ }
453
+ }
454
+ }
455
+ ```
456
+
412
457
  ---
413
458
 
414
459
  ## Requirements
@@ -5,6 +5,7 @@ src/qase/commons/__init__.py
5
5
  src/qase/commons/config.py
6
6
  src/qase/commons/loader.py
7
7
  src/qase/commons/logger.py
8
+ src/qase/commons/retry.py
8
9
  src/qase/commons/utils.py
9
10
  src/qase/commons/client/api_v1_client.py
10
11
  src/qase/commons/client/api_v2_client.py
@@ -1,15 +0,0 @@
1
- from ..basemodel import BaseModel
2
-
3
-
4
- class ApiConfig(BaseModel):
5
- token: str = None
6
- host: str = None
7
-
8
- def __init__(self):
9
- self.host = "qase.io"
10
-
11
- def set_token(self, token: str):
12
- self.token = token
13
-
14
- def set_host(self, host: str):
15
- self.host = host