locust-cloud 1.13.0__py3-none-any.whl → 1.14.0__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.
locust_cloud/cloud.py CHANGED
@@ -167,8 +167,6 @@ logging.basicConfig(
167
167
  )
168
168
  logger = logging.getLogger(__name__)
169
169
  # Restore log level for other libs. Yes, this can be done more nicely
170
- logging.getLogger("botocore").setLevel(logging.INFO)
171
- logging.getLogger("boto3").setLevel(logging.INFO)
172
170
  logging.getLogger("requests").setLevel(logging.INFO)
173
171
  logging.getLogger("urllib3").setLevel(logging.INFO)
174
172
 
@@ -368,6 +366,204 @@ class ApiSession(requests.Session):
368
366
  return super().request(method, f"{self.api_url}{url}", *args, **kwargs)
369
367
 
370
368
 
369
+ class SessionMismatchError(Exception):
370
+ pass
371
+
372
+
373
+ class WebsocketTimeout(Exception):
374
+ pass
375
+
376
+
377
+ class Websocket:
378
+ def __init__(self) -> None:
379
+ """
380
+ This class was created to encapsulate all the logic involved in the websocket implementation.
381
+ The behaviour of the socketio client once a connection has been established
382
+ is to try to reconnect forever if the connection is lost.
383
+ The way this can be canceled is by setting the _reconnect_abort (threading.Event) on the client
384
+ in which case it will simply proceed with shutting down without giving any indication of an error.
385
+ This class handles timeouts for connection attempts as well as some logic around when the
386
+ socket can be shut down. See descriptions on the methods for further details.
387
+ """
388
+ self.__shutdown_allowed = threading.Event()
389
+ self.__timeout_on_disconnect = True
390
+ self.initial_connect_timeout = 120
391
+ self.reconnect_timeout = 10
392
+ self.wait_timeout = 0
393
+ self.exception: None | Exception = None
394
+
395
+ self.sio = socketio.Client(handle_sigint=False)
396
+ self.sio._reconnect_abort = threading.Event()
397
+ # The _reconnect_abort value on the socketio client will be populated with a newly created threading.Event if it's not already set.
398
+ # There is no way to set this by passing it in the constructor.
399
+ # This event is the only way to interupt the retry logic when the connection is attempted.
400
+
401
+ self.sio.on("connect", self.__on_connect)
402
+ self.sio.on("disconnect", self.__on_disconnect)
403
+ self.sio.on("connect_error", self.__on_connect_error)
404
+ self.sio.on("events", self.__on_events)
405
+
406
+ self.__processed_events: set[int] = set()
407
+
408
+ def __set_connection_timeout(self, timeout) -> None:
409
+ """
410
+ Start a threading.Timer that will set the threading.Event on the socketio client
411
+ that aborts any further attempts to reconnect, sets an exception on the websocket
412
+ that will be raised from the wait method and the threading.Event __shutdown_allowed
413
+ on the websocket that tells the wait method that it should stop blocking.
414
+ """
415
+
416
+ def _timeout():
417
+ logger.debug(f"Websocket connection timed out after {timeout} seconds")
418
+ self.sio._reconnect_abort.set()
419
+ self.exception = WebsocketTimeout("Timed out connecting to locust master")
420
+ self.__shutdown_allowed.set()
421
+
422
+ self.__connect_timeout_timer = threading.Timer(timeout, _timeout)
423
+ self.__connect_timeout_timer.daemon = True
424
+ logger.debug(f"Setting websocket connection timeout to {timeout} seconds")
425
+ self.__connect_timeout_timer.start()
426
+
427
+ def connect(self, url, *, auth) -> None:
428
+ """
429
+ Send along retry=True when initiating the socketio client connection
430
+ to make it use it's builtin logic for retrying failed connections that
431
+ is usually used for reconnections. This will retry forever.
432
+ When connecting start a timer to trigger disabling the retry logic and
433
+ raise a WebsocketTimeout exception.
434
+ """
435
+ ws_connection_info = urllib.parse.urlparse(url)
436
+ self.__set_connection_timeout(self.initial_connect_timeout)
437
+ try:
438
+ self.sio.connect(
439
+ f"{ws_connection_info.scheme}://{ws_connection_info.netloc}",
440
+ auth=auth,
441
+ retry=True,
442
+ **{"socketio_path": ws_connection_info.path} if ws_connection_info.path else {},
443
+ )
444
+ except socketio.exceptions.ConnectionError:
445
+ if self.exception:
446
+ raise self.exception
447
+
448
+ raise
449
+
450
+ def shutdown(self) -> None:
451
+ """
452
+ When shutting down the socketio client a disconnect event will fire.
453
+ Before doing so disable the behaviour of starting a threading.Timer
454
+ to handle timeouts on attempts to reconnect since no further such attempts
455
+ will be made.
456
+ If such a timer is already running, cancel it since the client is being shutdown.
457
+ """
458
+ self.__timeout_on_disconnect = False
459
+ if hasattr(self, "__connect_timeout_timer"):
460
+ self.__connect_timeout_timer.cancel()
461
+ self.sio.shutdown()
462
+
463
+ def wait(self, timeout=False) -> bool:
464
+ """
465
+ Block until the threading.Event __shutdown_allowed is set, with a timeout if indicated.
466
+ If an exception has been set on the websocket (from a connection timeout timer or the
467
+ __on_connect_error method), raise it.
468
+ """
469
+ timeout = self.wait_timeout if timeout else None
470
+ logger.debug(f"Waiting for shutdown for {str(timeout)+'s' if timeout else 'ever'}")
471
+ res = self.__shutdown_allowed.wait(timeout)
472
+ if self.exception:
473
+ raise self.exception
474
+ return res
475
+
476
+ def __on_connect(self) -> None:
477
+ """
478
+ This gets events whenever a connection is successfully established.
479
+ When this happens, cancel the running threading.Timer that would
480
+ abort reconnect attempts and raise a WebsocketTimeout exception.
481
+ The wait_timeout is originally set to zero when creating the websocket
482
+ but once a connection has been established this is raised to ensure
483
+ that the server is given the chance to send all the logs and an
484
+ official shutdown event.
485
+ """
486
+ self.__connect_timeout_timer.cancel()
487
+ self.wait_timeout = 90
488
+ logger.debug("Websocket connected")
489
+
490
+ def __on_disconnect(self) -> None:
491
+ """
492
+ This gets events whenever a connection is lost.
493
+ The socketio client will try to reconnect forever so,
494
+ unless the behaviour has been disabled, a threading.Timer
495
+ is started that will abort reconnect attempts and raise a
496
+ WebsocketTimeout exception.
497
+ """
498
+ if self.__timeout_on_disconnect:
499
+ self.__set_connection_timeout(self.reconnect_timeout)
500
+ logger.debug("Websocket disconnected")
501
+
502
+ def __on_events(self, data):
503
+ """
504
+ This gets events explicitly sent by the websocket server.
505
+ This will either be messages to print on stdout/stderr or
506
+ an indication that the CLI can shut down in which case the
507
+ threading.Event __shutdown_allowed gets set on the websocket
508
+ that tells the wait method that it should stop blocking.
509
+ """
510
+ shutdown = False
511
+ shutdown_message = ""
512
+
513
+ if data["id"] in self.__processed_events:
514
+ logger.debug(f"Got duplicate data on websocket, id {data['id']}")
515
+ return
516
+
517
+ self.__processed_events.add(data["id"])
518
+
519
+ for event in data["events"]:
520
+ type = event["type"]
521
+
522
+ if type == "shutdown":
523
+ shutdown = True
524
+ shutdown_message = event["message"]
525
+ elif type == "stdout":
526
+ sys.stdout.write(event["message"])
527
+ elif type == "stderr":
528
+ sys.stderr.write(event["message"])
529
+ else:
530
+ raise Exception("Unexpected event type")
531
+
532
+ if shutdown:
533
+ logger.debug("Got shutdown from locust master")
534
+ if shutdown_message:
535
+ print(shutdown_message)
536
+
537
+ self.__shutdown_allowed.set()
538
+
539
+ def __on_connect_error(self, data) -> None:
540
+ """
541
+ This gets events whenever there's an error during connection attempts.
542
+ The specific case that is handled below is triggered when the connection
543
+ is made with the auth parameter not matching the session ID on the server.
544
+ If this error occurs it's because the connection is attempted towards an
545
+ instance of locust not started by this CLI.
546
+
547
+ In that case:
548
+ Cancel the running threading.Timer that would abort reconnect attempts
549
+ and raise a WebsocketTimeout exception.
550
+ Set an exception on the websocket that will be raised from the wait method.
551
+ Cancel further reconnect attempts.
552
+ Set the threading.Event __shutdown_allowed on the websocket that tells the
553
+ wait method that it should stop blocking.
554
+ """
555
+ # Do nothing if it's not the specific case we know how to deal with
556
+ if not (isinstance(data, dict) and data.get("message") == "Session mismatch"):
557
+ return
558
+
559
+ self.__connect_timeout_timer.cancel()
560
+ self.exception = SessionMismatchError(
561
+ "The session from this run of locust-cloud did not match the one on the server"
562
+ )
563
+ self.sio._reconnect_abort.set()
564
+ self.__shutdown_allowed.set()
565
+
566
+
371
567
  def main() -> None:
372
568
  if options.version:
373
569
  print(f"locust-cloud version {__version__}")
@@ -384,12 +580,13 @@ def main() -> None:
384
580
  sys.exit()
385
581
 
386
582
  session = ApiSession()
583
+ websocket = Websocket()
387
584
 
388
- try:
389
- if options.delete:
390
- delete(session)
391
- return
585
+ if options.delete:
586
+ delete(session)
587
+ sys.exit()
392
588
 
589
+ try:
393
590
  try:
394
591
  with open(options.locustfile, "rb") as f:
395
592
  locustfile_data = base64.b64encode(gzip.compress(f.read())).decode()
@@ -448,9 +645,7 @@ def main() -> None:
448
645
  logger.error(f"Failed to deploy the load generators: {e}")
449
646
  sys.exit(1)
450
647
 
451
- if response.status_code == 200:
452
- log_ws_url = response.json()["log_ws_url"]
453
- else:
648
+ if response.status_code != 200:
454
649
  try:
455
650
  logger.error(f"{response.json()['Message']} (HTTP {response.status_code}/{response.reason})")
456
651
  except Exception:
@@ -459,66 +654,33 @@ def main() -> None:
459
654
  )
460
655
  sys.exit(1)
461
656
 
462
- except KeyboardInterrupt:
463
- # TODO: This would potentially leave a deployment running, combine with try-catch below?
464
- logger.debug("Interrupted by user")
465
- sys.exit(0)
466
-
467
- logger.debug("Load generators deployed successfully!")
468
- logger.info("Waiting for pods to be ready...")
469
-
470
- shutdown_allowed = threading.Event()
471
- shutdown_allowed.set()
472
- reconnect_aborted = threading.Event()
473
- connect_timeout = threading.Timer(2 * 60, reconnect_aborted.set)
474
- sio = socketio.Client(handle_sigint=False)
475
-
476
- try:
477
- ws_connection_info = urllib.parse.urlparse(log_ws_url)
478
-
479
- @sio.event
480
- def connect():
481
- shutdown_allowed.clear()
482
- connect_timeout.cancel()
483
- logger.debug("Websocket connection established, switching to Locust logs")
484
-
485
- @sio.event
486
- def disconnect():
487
- logger.debug("Websocket disconnected")
488
-
489
- @sio.event
490
- def stderr(message):
491
- sys.stderr.write(message)
657
+ log_ws_url = response.json()["log_ws_url"]
658
+ session_id = response.json()["session_id"]
659
+ logger.debug(f"Session ID is {session_id}")
492
660
 
493
- @sio.event
494
- def stdout(message):
495
- sys.stdout.write(message)
496
-
497
- @sio.event
498
- def shutdown(message):
499
- logger.debug("Got shutdown from locust master")
500
- if message:
501
- print(message)
502
-
503
- shutdown_allowed.set()
504
-
505
- # The _reconnect_abort value on the socketio client will be populated with a newly created threading.Event if it's not already set.
506
- # There is no way to set this by passing it in the constructor.
507
- # This event is the only way to interupt the retry logic when the connection is attempted.
508
- sio._reconnect_abort = reconnect_aborted
509
- connect_timeout.start()
510
- sio.connect(
511
- f"{ws_connection_info.scheme}://{ws_connection_info.netloc}",
512
- socketio_path=ws_connection_info.path,
513
- retry=True,
661
+ logger.info("Waiting for pods to be ready...")
662
+ websocket.connect(
663
+ log_ws_url,
664
+ auth=session_id,
514
665
  )
515
- logger.debug("Waiting for shutdown")
516
- shutdown_allowed.wait()
666
+ websocket.wait()
517
667
 
518
668
  except KeyboardInterrupt:
519
669
  logger.debug("Interrupted by user")
520
670
  delete(session)
521
- shutdown_allowed.wait(timeout=90)
671
+ try:
672
+ websocket.wait(timeout=True)
673
+ except (WebsocketTimeout, SessionMismatchError) as e:
674
+ logger.error(str(e))
675
+ sys.exit(1)
676
+ except WebsocketTimeout as e:
677
+ logger.error(str(e))
678
+ delete(session)
679
+ sys.exit(1)
680
+ except SessionMismatchError as e:
681
+ # In this case we do not trigger the teardown since the running instance is not ours
682
+ logger.error(str(e))
683
+ sys.exit(1)
522
684
  except Exception as e:
523
685
  logger.exception(e)
524
686
  delete(session)
@@ -526,7 +688,8 @@ def main() -> None:
526
688
  else:
527
689
  delete(session)
528
690
  finally:
529
- sio.shutdown()
691
+ logger.debug("Shutting down websocket")
692
+ websocket.shutdown()
530
693
 
531
694
 
532
695
  def delete(session):
@@ -1,14 +1,14 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: locust-cloud
3
- Version: 1.13.0
3
+ Version: 1.14.0
4
4
  Summary: Locust Cloud
5
5
  Project-URL: Homepage, https://locust.cloud
6
6
  Requires-Python: >=3.11
7
- Requires-Dist: boto3==1.34.125
8
7
  Requires-Dist: configargparse==1.7
9
8
  Requires-Dist: platformdirs>=4.3.6
10
9
  Requires-Dist: pyjwt>=2.0
11
10
  Requires-Dist: python-socketio[client]==5.11.4
11
+ Requires-Dist: requests==2.32.3
12
12
  Description-Content-Type: text/markdown
13
13
 
14
14
  # Locust Cloud
@@ -0,0 +1,5 @@
1
+ locust_cloud/cloud.py,sha256=H_bATzG3Hyw8BRw0HmgLKTl5kX4y9KQuKCVTtzSAdjA,25466
2
+ locust_cloud-1.14.0.dist-info/METADATA,sha256=mZOGdXwLoyzOO3TeABH2eKsnw6RTz2d5Av0POdyvaIc,497
3
+ locust_cloud-1.14.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
4
+ locust_cloud-1.14.0.dist-info/entry_points.txt,sha256=PGyAb4e3aTsGS3N3VGShDl6VzJaXy7QwsEgsLOC7V00,57
5
+ locust_cloud-1.14.0.dist-info/RECORD,,
@@ -1,5 +0,0 @@
1
- locust_cloud/cloud.py,sha256=r0lWoP1QiT1Klkrecsr6LyPD_l1GMW2BFGRCRVkLowM,18124
2
- locust_cloud-1.13.0.dist-info/METADATA,sha256=STWxyZOw0qE003r-Pf-8RU3KPFK3yjH_NsSo5ffoHh0,496
3
- locust_cloud-1.13.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
4
- locust_cloud-1.13.0.dist-info/entry_points.txt,sha256=PGyAb4e3aTsGS3N3VGShDl6VzJaXy7QwsEgsLOC7V00,57
5
- locust_cloud-1.13.0.dist-info/RECORD,,