charisma-cli 0.1.2__py3-none-any.whl
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.
- charisma_cli/__init__.py +3 -0
- charisma_cli/config.py +86 -0
- charisma_cli/main.py +206 -0
- charisma_cli/models.py +76 -0
- charisma_cli/parser.py +175 -0
- charisma_cli/retry.py +18 -0
- charisma_cli/subprocess_mgr.py +63 -0
- charisma_cli/uploader.py +488 -0
- charisma_cli/watcher.py +223 -0
- charisma_cli-0.1.2.dist-info/METADATA +98 -0
- charisma_cli-0.1.2.dist-info/RECORD +13 -0
- charisma_cli-0.1.2.dist-info/WHEEL +4 -0
- charisma_cli-0.1.2.dist-info/entry_points.txt +2 -0
charisma_cli/uploader.py
ADDED
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
"""Queue consumer, batch accumulation, HTTP client, and retry logic for streaming uploads."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from queue import Empty, PriorityQueue
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from charisma_cli.config import Config
|
|
14
|
+
from charisma_cli.models import (
|
|
15
|
+
ContainerData,
|
|
16
|
+
FileCategory,
|
|
17
|
+
FileEvent,
|
|
18
|
+
UploadSummary,
|
|
19
|
+
)
|
|
20
|
+
from charisma_cli.parser import extract_attachment_refs, parse_container_file, parse_result_file
|
|
21
|
+
from charisma_cli.retry import MAX_RETRIES, RETRYABLE_STATUS_CODES, exponential_backoff
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
_BATCH_SIZE = 50
|
|
26
|
+
_BATCH_TIMEOUT_SECONDS = 2.0
|
|
27
|
+
_CONTAINER_ASSOCIATION_TIMEOUT = 60.0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _epoch_ms_to_iso(epoch_ms: int) -> str:
|
|
31
|
+
"""Convert epoch milliseconds to ISO 8601 UTC string."""
|
|
32
|
+
from datetime import datetime, timezone
|
|
33
|
+
dt = datetime.fromtimestamp(epoch_ms / 1000.0, tz=timezone.utc)
|
|
34
|
+
return dt.isoformat()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Uploader:
|
|
38
|
+
"""Consumes file events from a PriorityQueue, parses them, and streams to the Charisma API.
|
|
39
|
+
|
|
40
|
+
Runs in a background thread. Accumulates parsed results into batches
|
|
41
|
+
(flush at 50 items or 2s timeout). Handles retries, circuit breaking,
|
|
42
|
+
and 401 detection. All exceptions are caught internally.
|
|
43
|
+
|
|
44
|
+
Client lifecycle: The Uploader owns its httpx.Client. Call `ensure_client()`
|
|
45
|
+
before `open_launch()`/`close_launch()` if calling them outside of start/stop.
|
|
46
|
+
`start()` creates the client, `stop()` closes it.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, config: Config, queue: PriorityQueue[FileEvent]) -> None:
|
|
50
|
+
self._config = config
|
|
51
|
+
self._queue = queue
|
|
52
|
+
self._summary = UploadSummary()
|
|
53
|
+
self._launch_id: str | None = None
|
|
54
|
+
self._disabled = False
|
|
55
|
+
self._auth_failed = False
|
|
56
|
+
self._stop_event = threading.Event()
|
|
57
|
+
self._drain_event = threading.Event()
|
|
58
|
+
self._thread: threading.Thread | None = None
|
|
59
|
+
self._client: httpx.Client | None = None
|
|
60
|
+
self._batch: list[dict[str, Any]] = []
|
|
61
|
+
self._batch_start: float = 0.0
|
|
62
|
+
self._pending_containers: list[ContainerData] = []
|
|
63
|
+
self._known_result_uuids: set[str] = set()
|
|
64
|
+
# Map source filename → result_uuid for attachment uploads
|
|
65
|
+
self._attachment_result_map: dict[str, str] = {}
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def summary(self) -> UploadSummary:
|
|
69
|
+
"""Return current upload statistics."""
|
|
70
|
+
return self._summary
|
|
71
|
+
|
|
72
|
+
def configure_logging(self) -> None:
|
|
73
|
+
"""Configure logging based on silent mode.
|
|
74
|
+
|
|
75
|
+
When silent=True, upload-related errors are routed to DEBUG level only
|
|
76
|
+
(no stderr output). When silent=False, errors are logged at ERROR level.
|
|
77
|
+
"""
|
|
78
|
+
cli_logger = logging.getLogger("charisma_cli")
|
|
79
|
+
if self._config.silent:
|
|
80
|
+
cli_logger.setLevel(logging.CRITICAL)
|
|
81
|
+
else:
|
|
82
|
+
cli_logger.setLevel(logging.WARNING)
|
|
83
|
+
if not cli_logger.handlers:
|
|
84
|
+
handler = logging.StreamHandler()
|
|
85
|
+
handler.setLevel(logging.WARNING)
|
|
86
|
+
cli_logger.addHandler(handler)
|
|
87
|
+
|
|
88
|
+
def ensure_client(self) -> None:
|
|
89
|
+
"""Create the httpx client if not already created.
|
|
90
|
+
|
|
91
|
+
Idempotent — safe to call multiple times.
|
|
92
|
+
"""
|
|
93
|
+
if self._client is None:
|
|
94
|
+
self._client = httpx.Client(
|
|
95
|
+
base_url=self._config.endpoint,
|
|
96
|
+
headers={"Authorization": f"Bearer {self._config.token}"},
|
|
97
|
+
timeout=30.0,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def start(self) -> None:
|
|
101
|
+
"""Start the uploader consumer thread."""
|
|
102
|
+
self.ensure_client()
|
|
103
|
+
self._thread = threading.Thread(target=self._run, daemon=True, name="charisma-uploader")
|
|
104
|
+
self._thread.start()
|
|
105
|
+
|
|
106
|
+
def stop(self) -> None:
|
|
107
|
+
"""Signal the uploader thread to exit and wait for it."""
|
|
108
|
+
self._stop_event.set()
|
|
109
|
+
if self._thread is not None:
|
|
110
|
+
self._thread.join(timeout=10.0)
|
|
111
|
+
if self._client is not None:
|
|
112
|
+
self._client.close()
|
|
113
|
+
self._client = None
|
|
114
|
+
|
|
115
|
+
def drain(self, timeout: float) -> None:
|
|
116
|
+
"""Signal drain and wait for queue to empty or timeout to expire.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
timeout: Maximum seconds to wait for the queue to drain.
|
|
120
|
+
"""
|
|
121
|
+
self._drain_event.set()
|
|
122
|
+
deadline = time.monotonic() + timeout
|
|
123
|
+
while not self._queue.empty() and time.monotonic() < deadline:
|
|
124
|
+
time.sleep(0.1)
|
|
125
|
+
if not self._queue.empty():
|
|
126
|
+
logger.warning("Drain timeout expired with %d items remaining in queue", self._queue.qsize())
|
|
127
|
+
|
|
128
|
+
def open_launch(self) -> str | None:
|
|
129
|
+
"""Open a streaming launch via the API.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
The launch ID if successful, None if the open failed.
|
|
133
|
+
"""
|
|
134
|
+
self.ensure_client()
|
|
135
|
+
|
|
136
|
+
payload: dict[str, Any] = {
|
|
137
|
+
"projectAlias": self._config.project,
|
|
138
|
+
"expectedTests": 1000,
|
|
139
|
+
}
|
|
140
|
+
if self._config.build_id:
|
|
141
|
+
payload["buildId"] = self._config.build_id
|
|
142
|
+
if self._config.commit_sha:
|
|
143
|
+
payload["commitSha"] = self._config.commit_sha
|
|
144
|
+
if self._config.branch:
|
|
145
|
+
payload["branch"] = self._config.branch
|
|
146
|
+
|
|
147
|
+
for attempt in range(MAX_RETRIES + 1):
|
|
148
|
+
try:
|
|
149
|
+
response = self._client.post("/api/v1/launches", json=payload)
|
|
150
|
+
if response.status_code == 201:
|
|
151
|
+
data = response.json()
|
|
152
|
+
self._launch_id = data["launchId"]
|
|
153
|
+
return self._launch_id
|
|
154
|
+
if response.status_code in RETRYABLE_STATUS_CODES and attempt < MAX_RETRIES:
|
|
155
|
+
time.sleep(exponential_backoff(attempt))
|
|
156
|
+
continue
|
|
157
|
+
logger.error("Failed to open launch: HTTP %d", response.status_code)
|
|
158
|
+
return None
|
|
159
|
+
except (httpx.ConnectError, httpx.TimeoutException) as e:
|
|
160
|
+
if attempt < MAX_RETRIES:
|
|
161
|
+
time.sleep(exponential_backoff(attempt))
|
|
162
|
+
continue
|
|
163
|
+
logger.error("Failed to open launch after retries: %s", e)
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
def close_launch(self) -> None:
|
|
169
|
+
"""Close the streaming launch via the API."""
|
|
170
|
+
self.ensure_client()
|
|
171
|
+
if self._launch_id is None:
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
for attempt in range(MAX_RETRIES + 1):
|
|
175
|
+
try:
|
|
176
|
+
response = self._client.post(f"/api/v1/launches/{self._launch_id}/close")
|
|
177
|
+
if response.status_code in (200, 201, 204):
|
|
178
|
+
return
|
|
179
|
+
if response.status_code in RETRYABLE_STATUS_CODES and attempt < MAX_RETRIES:
|
|
180
|
+
time.sleep(exponential_backoff(attempt))
|
|
181
|
+
continue
|
|
182
|
+
logger.warning("Failed to close launch: HTTP %d", response.status_code)
|
|
183
|
+
return
|
|
184
|
+
except (httpx.ConnectError, httpx.TimeoutException) as e:
|
|
185
|
+
if attempt < MAX_RETRIES:
|
|
186
|
+
time.sleep(exponential_backoff(attempt))
|
|
187
|
+
continue
|
|
188
|
+
logger.warning("Failed to close launch after retries: %s", e)
|
|
189
|
+
return
|
|
190
|
+
|
|
191
|
+
def _run(self) -> None:
|
|
192
|
+
"""Main consumer loop running in the background thread."""
|
|
193
|
+
try:
|
|
194
|
+
while not self._stop_event.is_set():
|
|
195
|
+
self._process_queue_item()
|
|
196
|
+
self._check_batch_timeout()
|
|
197
|
+
# On stop, flush remaining
|
|
198
|
+
self._flush_remaining()
|
|
199
|
+
except Exception:
|
|
200
|
+
logger.exception("Unexpected error in uploader thread")
|
|
201
|
+
|
|
202
|
+
def _process_queue_item(self) -> None:
|
|
203
|
+
"""Try to dequeue and process one item."""
|
|
204
|
+
try:
|
|
205
|
+
event: FileEvent = self._queue.get(timeout=0.1)
|
|
206
|
+
except Empty:
|
|
207
|
+
return
|
|
208
|
+
|
|
209
|
+
if self._disabled or self._auth_failed:
|
|
210
|
+
self._queue.task_done()
|
|
211
|
+
return
|
|
212
|
+
|
|
213
|
+
try:
|
|
214
|
+
self._handle_event(event)
|
|
215
|
+
except Exception:
|
|
216
|
+
logger.exception("Error processing file: %s", event.path)
|
|
217
|
+
finally:
|
|
218
|
+
self._queue.task_done()
|
|
219
|
+
|
|
220
|
+
def _handle_event(self, event: FileEvent) -> None:
|
|
221
|
+
"""Route a file event to the appropriate handler."""
|
|
222
|
+
if event.category == FileCategory.RESULT:
|
|
223
|
+
self._handle_result(event.path)
|
|
224
|
+
elif event.category == FileCategory.CONTAINER:
|
|
225
|
+
self._handle_container(event.path)
|
|
226
|
+
elif event.category == FileCategory.ATTACHMENT:
|
|
227
|
+
self._handle_attachment(event.path)
|
|
228
|
+
|
|
229
|
+
def _handle_result(self, path: Path) -> None:
|
|
230
|
+
"""Parse a result file and add to the current batch.
|
|
231
|
+
|
|
232
|
+
Reads the file once, uses parsed data for both the batch payload
|
|
233
|
+
and attachment reference extraction.
|
|
234
|
+
"""
|
|
235
|
+
# Read file once
|
|
236
|
+
try:
|
|
237
|
+
raw_text = path.read_text(encoding="utf-8")
|
|
238
|
+
except (OSError, FileNotFoundError):
|
|
239
|
+
logger.warning("Cannot read result file: %s", path)
|
|
240
|
+
self._summary.results_failed += 1
|
|
241
|
+
return
|
|
242
|
+
|
|
243
|
+
try:
|
|
244
|
+
raw_data = json.loads(raw_text)
|
|
245
|
+
except (json.JSONDecodeError, ValueError):
|
|
246
|
+
logger.warning("Malformed JSON in result file: %s", path)
|
|
247
|
+
self._summary.results_failed += 1
|
|
248
|
+
return
|
|
249
|
+
|
|
250
|
+
result = parse_result_file(path)
|
|
251
|
+
if result is None:
|
|
252
|
+
self._summary.results_failed += 1
|
|
253
|
+
return
|
|
254
|
+
|
|
255
|
+
# Track UUID for container association
|
|
256
|
+
if result.uuid:
|
|
257
|
+
self._known_result_uuids.add(result.uuid)
|
|
258
|
+
|
|
259
|
+
# Build API payload
|
|
260
|
+
payload: dict[str, Any] = {
|
|
261
|
+
"testId": result.testId,
|
|
262
|
+
"outcome": result.outcome,
|
|
263
|
+
"duration_ms": result.duration_ms,
|
|
264
|
+
}
|
|
265
|
+
if result.name:
|
|
266
|
+
payload["name"] = result.name
|
|
267
|
+
if result.full_name:
|
|
268
|
+
payload["fullName"] = result.full_name
|
|
269
|
+
if result.error_message:
|
|
270
|
+
payload["error_message"] = result.error_message
|
|
271
|
+
if result.stack_trace:
|
|
272
|
+
payload["stack_trace"] = result.stack_trace
|
|
273
|
+
if result.labels:
|
|
274
|
+
# Convert Allure's [{name, value}] array to {name: value} dict
|
|
275
|
+
# (matches batch ingestion format expected by frontend)
|
|
276
|
+
labels_dict: dict[str, str] = {}
|
|
277
|
+
for label in result.labels:
|
|
278
|
+
name_key = label.get("name", "")
|
|
279
|
+
value_str = label.get("value", "")
|
|
280
|
+
if name_key and value_str:
|
|
281
|
+
labels_dict[name_key] = value_str
|
|
282
|
+
payload["labels"] = labels_dict
|
|
283
|
+
if result.parameters:
|
|
284
|
+
# Convert Allure's [{name, value}] array to {name: value} dict
|
|
285
|
+
params_dict: dict[str, str] = {}
|
|
286
|
+
for param in result.parameters:
|
|
287
|
+
name_key = param.get("name", "")
|
|
288
|
+
value_str = param.get("value", "")
|
|
289
|
+
if name_key:
|
|
290
|
+
params_dict[name_key] = value_str
|
|
291
|
+
payload["parameters"] = params_dict
|
|
292
|
+
if result.started_at is not None:
|
|
293
|
+
payload["started_at"] = _epoch_ms_to_iso(result.started_at)
|
|
294
|
+
if result.ended_at is not None:
|
|
295
|
+
payload["ended_at"] = _epoch_ms_to_iso(result.ended_at)
|
|
296
|
+
|
|
297
|
+
# Serialize Allure steps and links into labels dict (per design field mapping)
|
|
298
|
+
steps = raw_data.get("steps")
|
|
299
|
+
if steps:
|
|
300
|
+
# Normalize Allure step format to match the schema _parse_steps expects
|
|
301
|
+
normalized_steps = []
|
|
302
|
+
for step in steps:
|
|
303
|
+
status_details = step.get("statusDetails") or {}
|
|
304
|
+
start = step.get("start") or 0
|
|
305
|
+
stop = step.get("stop") or 0
|
|
306
|
+
normalized_steps.append({
|
|
307
|
+
"name": step.get("name", ""),
|
|
308
|
+
"status": step.get("status", ""),
|
|
309
|
+
"duration_ms": (stop - start) if (start and stop) else None,
|
|
310
|
+
"error_message": status_details.get("message"),
|
|
311
|
+
})
|
|
312
|
+
labels_d = payload.get("labels", {})
|
|
313
|
+
labels_d["steps"] = json.dumps(normalized_steps)
|
|
314
|
+
payload["labels"] = labels_d
|
|
315
|
+
|
|
316
|
+
links = raw_data.get("links")
|
|
317
|
+
if links:
|
|
318
|
+
labels_d = payload.get("labels", {})
|
|
319
|
+
labels_d["links"] = json.dumps(links)
|
|
320
|
+
payload["labels"] = labels_d
|
|
321
|
+
|
|
322
|
+
# Add description to labels if present
|
|
323
|
+
description = raw_data.get("description") or raw_data.get("descriptionHtml")
|
|
324
|
+
if description:
|
|
325
|
+
labels_d = payload.get("labels", {})
|
|
326
|
+
labels_d["description"] = description
|
|
327
|
+
payload["labels"] = labels_d
|
|
328
|
+
|
|
329
|
+
self._batch.append(payload)
|
|
330
|
+
if not self._batch_start:
|
|
331
|
+
self._batch_start = time.monotonic()
|
|
332
|
+
|
|
333
|
+
# Flush if batch is full
|
|
334
|
+
if len(self._batch) >= _BATCH_SIZE:
|
|
335
|
+
self._flush_batch()
|
|
336
|
+
|
|
337
|
+
# Extract attachment refs and register them for upload with correct result_uuid
|
|
338
|
+
try:
|
|
339
|
+
refs = extract_attachment_refs(raw_data, result.uuid or "")
|
|
340
|
+
for ref in refs:
|
|
341
|
+
# Register source → result_uuid mapping so _handle_attachment knows the owner
|
|
342
|
+
self._attachment_result_map[ref.source] = ref.result_uuid
|
|
343
|
+
except Exception:
|
|
344
|
+
logger.debug("Failed to extract attachment refs from %s", path, exc_info=True)
|
|
345
|
+
|
|
346
|
+
def _handle_container(self, path: Path) -> None:
|
|
347
|
+
"""Parse a container file and buffer for deferred association."""
|
|
348
|
+
container = parse_container_file(path)
|
|
349
|
+
if container is None:
|
|
350
|
+
return
|
|
351
|
+
|
|
352
|
+
container.received_at = time.monotonic()
|
|
353
|
+
self._pending_containers.append(container)
|
|
354
|
+
self._try_associate_containers()
|
|
355
|
+
|
|
356
|
+
def _handle_attachment(self, path: Path) -> None:
|
|
357
|
+
"""Upload an attachment file to the Charisma attachment API."""
|
|
358
|
+
if not self._launch_id or not path.exists():
|
|
359
|
+
self._summary.attachments_skipped += 1
|
|
360
|
+
return
|
|
361
|
+
|
|
362
|
+
# Look up the result_uuid for this attachment
|
|
363
|
+
result_uuid = self._attachment_result_map.get(path.name, "")
|
|
364
|
+
if not result_uuid:
|
|
365
|
+
# Attachment not referenced by any result — skip
|
|
366
|
+
self._summary.attachments_skipped += 1
|
|
367
|
+
logger.debug("Skipping attachment with no result reference: %s", path.name)
|
|
368
|
+
return
|
|
369
|
+
|
|
370
|
+
try:
|
|
371
|
+
content = path.read_bytes()
|
|
372
|
+
files = {"file": (path.name, content)}
|
|
373
|
+
data = {"name": path.name, "resultId": result_uuid}
|
|
374
|
+
|
|
375
|
+
response = self._client.post(
|
|
376
|
+
f"/api/v1/launches/{self._launch_id}/attachments",
|
|
377
|
+
files=files,
|
|
378
|
+
data=data,
|
|
379
|
+
)
|
|
380
|
+
if response.status_code == 201:
|
|
381
|
+
self._summary.attachments_sent += 1
|
|
382
|
+
else:
|
|
383
|
+
self._summary.attachments_skipped += 1
|
|
384
|
+
logger.debug("Attachment upload failed: HTTP %d for %s", response.status_code, path.name)
|
|
385
|
+
except (httpx.ConnectError, httpx.TimeoutException) as e:
|
|
386
|
+
self._summary.attachments_skipped += 1
|
|
387
|
+
logger.debug("Attachment upload connection error: %s for %s", e, path.name)
|
|
388
|
+
except Exception:
|
|
389
|
+
self._summary.attachments_skipped += 1
|
|
390
|
+
logger.debug("Attachment upload unexpected error for %s", path.name, exc_info=True)
|
|
391
|
+
|
|
392
|
+
def _try_associate_containers(self) -> None:
|
|
393
|
+
"""Try to associate buffered containers with known results."""
|
|
394
|
+
now = time.monotonic()
|
|
395
|
+
still_pending: list[ContainerData] = []
|
|
396
|
+
for container in self._pending_containers:
|
|
397
|
+
if now - container.received_at > _CONTAINER_ASSOCIATION_TIMEOUT:
|
|
398
|
+
self._summary.containers_sent += 1
|
|
399
|
+
continue
|
|
400
|
+
if all(child in self._known_result_uuids for child in container.children):
|
|
401
|
+
self._summary.containers_sent += 1
|
|
402
|
+
else:
|
|
403
|
+
still_pending.append(container)
|
|
404
|
+
self._pending_containers = still_pending
|
|
405
|
+
|
|
406
|
+
def _check_batch_timeout(self) -> None:
|
|
407
|
+
"""Flush batch if timeout has elapsed."""
|
|
408
|
+
if self._batch and self._batch_start:
|
|
409
|
+
elapsed = time.monotonic() - self._batch_start
|
|
410
|
+
if elapsed >= _BATCH_TIMEOUT_SECONDS:
|
|
411
|
+
self._flush_batch()
|
|
412
|
+
|
|
413
|
+
def _flush_batch(self) -> None:
|
|
414
|
+
"""Send the current batch to the API."""
|
|
415
|
+
if not self._batch or not self._launch_id:
|
|
416
|
+
return
|
|
417
|
+
|
|
418
|
+
batch = self._batch[:]
|
|
419
|
+
self._batch = []
|
|
420
|
+
self._batch_start = 0.0
|
|
421
|
+
|
|
422
|
+
success = self._send_batch(batch)
|
|
423
|
+
if success:
|
|
424
|
+
self._summary.results_sent += len(batch)
|
|
425
|
+
else:
|
|
426
|
+
self._summary.results_failed += len(batch)
|
|
427
|
+
|
|
428
|
+
def _send_batch(self, batch: list[dict[str, Any]]) -> bool:
|
|
429
|
+
"""Send a batch of results to the API with retry logic.
|
|
430
|
+
|
|
431
|
+
Retries on transient errors (5xx, 429, timeout, ConnectError).
|
|
432
|
+
Engages circuit breaker only after all retries are exhausted for ConnectError.
|
|
433
|
+
|
|
434
|
+
Returns:
|
|
435
|
+
True if the batch was accepted, False otherwise.
|
|
436
|
+
"""
|
|
437
|
+
if self._client is None:
|
|
438
|
+
return False
|
|
439
|
+
|
|
440
|
+
for attempt in range(MAX_RETRIES + 1):
|
|
441
|
+
try:
|
|
442
|
+
response = self._client.post(
|
|
443
|
+
f"/api/v1/launches/{self._launch_id}/results",
|
|
444
|
+
json={"results": batch},
|
|
445
|
+
)
|
|
446
|
+
if response.status_code in (200, 201):
|
|
447
|
+
return True
|
|
448
|
+
if response.status_code == 401:
|
|
449
|
+
logger.error("Authentication failed (401) — disabling uploads")
|
|
450
|
+
self._auth_failed = True
|
|
451
|
+
return False
|
|
452
|
+
if response.status_code in RETRYABLE_STATUS_CODES and attempt < MAX_RETRIES:
|
|
453
|
+
time.sleep(exponential_backoff(attempt))
|
|
454
|
+
continue
|
|
455
|
+
# Non-retryable failure
|
|
456
|
+
logger.error(
|
|
457
|
+
"Batch upload failed: HTTP %d — %s",
|
|
458
|
+
response.status_code,
|
|
459
|
+
response.text[:500],
|
|
460
|
+
)
|
|
461
|
+
return False
|
|
462
|
+
except (httpx.ConnectError, httpx.TimeoutException) as e:
|
|
463
|
+
if attempt < MAX_RETRIES:
|
|
464
|
+
time.sleep(exponential_backoff(attempt))
|
|
465
|
+
continue
|
|
466
|
+
# All retries exhausted — engage circuit breaker
|
|
467
|
+
logger.error("API unreachable after %d retries: %s — engaging circuit breaker", MAX_RETRIES, e)
|
|
468
|
+
self._disabled = True
|
|
469
|
+
return False
|
|
470
|
+
|
|
471
|
+
return False
|
|
472
|
+
|
|
473
|
+
def _flush_remaining(self) -> None:
|
|
474
|
+
"""Flush any remaining batch and process remaining queue items."""
|
|
475
|
+
while not self._queue.empty():
|
|
476
|
+
try:
|
|
477
|
+
event = self._queue.get_nowait()
|
|
478
|
+
if not self._disabled and not self._auth_failed:
|
|
479
|
+
self._handle_event(event)
|
|
480
|
+
self._queue.task_done()
|
|
481
|
+
except Empty:
|
|
482
|
+
break
|
|
483
|
+
|
|
484
|
+
if self._batch:
|
|
485
|
+
self._flush_batch()
|
|
486
|
+
|
|
487
|
+
self._summary.containers_sent += len(self._pending_containers)
|
|
488
|
+
self._pending_containers.clear()
|