crate 2.0.0.dev0__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.
crate/client/http.py ADDED
@@ -0,0 +1,694 @@
1
+ # -*- coding: utf-8; -*-
2
+ #
3
+ # Licensed to CRATE Technology GmbH ("Crate") under one or more contributor
4
+ # license agreements. See the NOTICE file distributed with this work for
5
+ # additional information regarding copyright ownership. Crate licenses
6
+ # this file to you under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License. You may
8
+ # obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
14
+ # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
15
+ # License for the specific language governing permissions and limitations
16
+ # under the License.
17
+ #
18
+ # However, if you have executed another commercial license agreement
19
+ # with Crate these terms will supersede the license and you may use the
20
+ # software solely pursuant to the terms of the relevant commercial agreement.
21
+
22
+
23
+ import calendar
24
+ import heapq
25
+ import io
26
+ import logging
27
+ import os
28
+ import re
29
+ import socket
30
+ import ssl
31
+ import threading
32
+ from base64 import b64encode
33
+ from datetime import date, datetime, timezone
34
+ from decimal import Decimal
35
+ from time import time
36
+ from urllib.parse import urlparse
37
+ from uuid import UUID
38
+
39
+ import orjson
40
+ import urllib3
41
+ from urllib3 import connection_from_url
42
+ from urllib3.connection import HTTPConnection
43
+ from urllib3.exceptions import (
44
+ HTTPError,
45
+ MaxRetryError,
46
+ ProtocolError,
47
+ ProxyError,
48
+ ReadTimeoutError,
49
+ SSLError,
50
+ )
51
+ from urllib3.util.retry import Retry
52
+ from verlib2 import Version
53
+
54
+ from crate.client.exceptions import (
55
+ BlobLocationNotFoundException,
56
+ ConnectionError,
57
+ DigestNotFoundException,
58
+ IntegrityError,
59
+ ProgrammingError,
60
+ )
61
+
62
+ logger = logging.getLogger(__name__)
63
+
64
+
65
+ _HTTP_PAT = pat = re.compile("https?://.+", re.I)
66
+ SRV_UNAVAILABLE_STATUSES = {502, 503, 504, 509}
67
+ PRESERVE_ACTIVE_SERVER_EXCEPTIONS = {ConnectionResetError, BrokenPipeError}
68
+ SSL_ONLY_ARGS = {"ca_certs", "cert_reqs", "cert_file", "key_file"}
69
+
70
+
71
+ def super_len(o):
72
+ if hasattr(o, "__len__"):
73
+ return len(o)
74
+ if hasattr(o, "len"):
75
+ return o.len
76
+ if hasattr(o, "fileno"):
77
+ try:
78
+ fileno = o.fileno()
79
+ except io.UnsupportedOperation:
80
+ pass
81
+ else:
82
+ return os.fstat(fileno).st_size
83
+ if hasattr(o, "getvalue"):
84
+ # e.g. BytesIO, cStringIO.StringI
85
+ return len(o.getvalue())
86
+ return None
87
+
88
+
89
+ epoch_aware = datetime(1970, 1, 1, tzinfo=timezone.utc)
90
+ epoch_naive = datetime(1970, 1, 1)
91
+
92
+
93
+ def cratedb_json_encoder(obj):
94
+ """
95
+ Encoder function for orjson.
96
+
97
+ https://github.com/ijl/orjson#default
98
+ https://github.com/ijl/orjson#opt_passthrough_datetime
99
+ """
100
+ if isinstance(obj, (Decimal, UUID)):
101
+ return str(obj)
102
+ if isinstance(obj, datetime):
103
+ if obj.tzinfo is not None:
104
+ delta = obj - epoch_aware
105
+ else:
106
+ delta = obj - epoch_naive
107
+ return int(
108
+ delta.microseconds / 1000.0
109
+ + (delta.seconds + delta.days * 24 * 3600) * 1000.0
110
+ )
111
+ if isinstance(obj, date):
112
+ return calendar.timegm(obj.timetuple()) * 1000
113
+ return obj
114
+
115
+
116
+ class Server:
117
+ def __init__(self, server, **pool_kw):
118
+ socket_options = _get_socket_opts(
119
+ pool_kw.pop("socket_keepalive", False),
120
+ pool_kw.pop("socket_tcp_keepidle", None),
121
+ pool_kw.pop("socket_tcp_keepintvl", None),
122
+ pool_kw.pop("socket_tcp_keepcnt", None),
123
+ )
124
+ self.pool = connection_from_url(
125
+ server,
126
+ socket_options=socket_options,
127
+ **pool_kw,
128
+ )
129
+
130
+ def request(
131
+ self,
132
+ method,
133
+ path,
134
+ data=None,
135
+ stream=False,
136
+ headers=None,
137
+ username=None,
138
+ password=None,
139
+ schema=None,
140
+ backoff_factor=0,
141
+ **kwargs,
142
+ ):
143
+ """Send a request
144
+
145
+ Always set the Content-Length and the Content-Type header.
146
+ """
147
+ if headers is None:
148
+ headers = {}
149
+ if "Content-Length" not in headers:
150
+ length = super_len(data)
151
+ if length is not None:
152
+ headers["Content-Length"] = length
153
+
154
+ # Authentication credentials
155
+ if username is not None:
156
+ if "Authorization" not in headers and username is not None:
157
+ credentials = username + ":"
158
+ if password is not None:
159
+ credentials += password
160
+ headers["Authorization"] = "Basic %s" % b64encode(
161
+ credentials.encode("utf-8")
162
+ ).decode("utf-8")
163
+ # For backwards compatibility with Crate <= 2.2
164
+ if "X-User" not in headers:
165
+ headers["X-User"] = username
166
+
167
+ if schema is not None:
168
+ headers["Default-Schema"] = schema
169
+ headers["Accept"] = "application/json"
170
+ headers["Content-Type"] = "application/json"
171
+ kwargs["assert_same_host"] = False
172
+ kwargs["redirect"] = False
173
+ kwargs["retries"] = Retry(read=0, backoff_factor=backoff_factor)
174
+ return self.pool.urlopen(
175
+ method,
176
+ path,
177
+ body=data,
178
+ preload_content=not stream,
179
+ headers=headers,
180
+ **kwargs,
181
+ )
182
+
183
+ def close(self):
184
+ self.pool.close()
185
+
186
+
187
+ def _json_from_response(response):
188
+ try:
189
+ return orjson.loads(response.data)
190
+ except ValueError as ex:
191
+ raise ProgrammingError(
192
+ "Invalid server response of content-type '{}':\n{}".format(
193
+ response.headers.get("content-type", "unknown"),
194
+ response.data.decode("utf-8"),
195
+ )
196
+ ) from ex
197
+
198
+
199
+ def _blob_path(table, digest):
200
+ return "/_blobs/{table}/{digest}".format(table=table, digest=digest)
201
+
202
+
203
+ def _ex_to_message(ex):
204
+ return getattr(ex, "message", None) or str(ex) or repr(ex)
205
+
206
+
207
+ def _raise_for_status(response):
208
+ """
209
+ Raise `IntegrityError` exceptions for `DuplicateKeyException` errors.
210
+ """
211
+ try:
212
+ return _raise_for_status_real(response)
213
+ except ProgrammingError as ex:
214
+ if "DuplicateKeyException" in ex.message:
215
+ raise IntegrityError(ex.message, error_trace=ex.error_trace) from ex
216
+ raise
217
+
218
+
219
+ def _raise_for_status_real(response):
220
+ """make sure that only crate.exceptions are raised that are defined in
221
+ the DB-API specification"""
222
+ message = ""
223
+ if 400 <= response.status < 500:
224
+ message = "%s Client Error: %s" % (response.status, response.reason)
225
+ elif 500 <= response.status < 600:
226
+ message = "%s Server Error: %s" % (response.status, response.reason)
227
+ else:
228
+ return
229
+ if response.status == 503:
230
+ raise ConnectionError(message)
231
+ if response.headers.get("content-type", "").startswith("application/json"):
232
+ data = orjson.loads(response.data)
233
+ error = data.get("error", {})
234
+ error_trace = data.get("error_trace", None)
235
+ if "results" in data:
236
+ errors = [
237
+ res["error_message"]
238
+ for res in data["results"]
239
+ if res.get("error_message")
240
+ ]
241
+ if errors:
242
+ raise ProgrammingError("\n".join(errors))
243
+ if isinstance(error, dict):
244
+ raise ProgrammingError(
245
+ error.get("message", ""), error_trace=error_trace
246
+ )
247
+ raise ProgrammingError(error, error_trace=error_trace)
248
+ raise ProgrammingError(message)
249
+
250
+
251
+ def _server_url(server):
252
+ """
253
+ Normalizes a given server string to an url
254
+
255
+ >>> print(_server_url('a'))
256
+ http://a
257
+ >>> print(_server_url('a:9345'))
258
+ http://a:9345
259
+ >>> print(_server_url('https://a:9345'))
260
+ https://a:9345
261
+ >>> print(_server_url('https://a'))
262
+ https://a
263
+ >>> print(_server_url('demo.crate.io'))
264
+ http://demo.crate.io
265
+ """
266
+ if not _HTTP_PAT.match(server):
267
+ server = "http://%s" % server
268
+ parsed = urlparse(server)
269
+ url = "%s://%s" % (parsed.scheme, parsed.netloc)
270
+ return url
271
+
272
+
273
+ def _to_server_list(servers):
274
+ if isinstance(servers, str):
275
+ servers = servers.split()
276
+ return [_server_url(s) for s in servers]
277
+
278
+
279
+ def _pool_kw_args(
280
+ verify_ssl_cert,
281
+ ca_cert,
282
+ client_cert,
283
+ client_key,
284
+ timeout=None,
285
+ pool_size=None,
286
+ ):
287
+ ca_cert = ca_cert or os.environ.get("REQUESTS_CA_BUNDLE", None)
288
+ if ca_cert and not os.path.exists(ca_cert):
289
+ # Sanity check
290
+ raise IOError('CA bundle file "{}" does not exist.'.format(ca_cert))
291
+
292
+ kw = {
293
+ "ca_certs": ca_cert,
294
+ "cert_reqs": ssl.CERT_REQUIRED if verify_ssl_cert else ssl.CERT_NONE,
295
+ "cert_file": client_cert,
296
+ "key_file": client_key,
297
+ }
298
+ if timeout is not None:
299
+ if isinstance(timeout, str):
300
+ timeout = float(timeout)
301
+ kw["timeout"] = timeout
302
+ if pool_size is not None:
303
+ kw["maxsize"] = int(pool_size)
304
+ return kw
305
+
306
+
307
+ def _remove_certs_for_non_https(server, kwargs):
308
+ if server.lower().startswith("https"):
309
+ return kwargs
310
+ used_ssl_args = SSL_ONLY_ARGS & set(kwargs.keys())
311
+ if used_ssl_args:
312
+ kwargs = kwargs.copy()
313
+ for arg in used_ssl_args:
314
+ kwargs.pop(arg)
315
+ return kwargs
316
+
317
+
318
+ def _update_pool_kwargs_for_ssl_minimum_version(server, kwargs):
319
+ """
320
+ On urllib3 v2, re-add support for TLS 1.0 and TLS 1.1.
321
+
322
+ https://urllib3.readthedocs.io/en/latest/v2-migration-guide.html#https-requires-tls-1-2
323
+ """
324
+ if Version(urllib3.__version__) >= Version("2"):
325
+ from urllib3.util import parse_url
326
+
327
+ scheme, _, host, port, *_ = parse_url(server)
328
+ if scheme == "https":
329
+ kwargs["ssl_minimum_version"] = ssl.TLSVersion.MINIMUM_SUPPORTED
330
+
331
+
332
+ def _create_sql_payload(stmt, args, bulk_args):
333
+ if not isinstance(stmt, str):
334
+ raise ValueError("stmt is not a string")
335
+ if args and bulk_args:
336
+ raise ValueError("Cannot provide both: args and bulk_args")
337
+
338
+ data = {"stmt": stmt}
339
+ if args:
340
+ data["args"] = args
341
+ if bulk_args:
342
+ data["bulk_args"] = bulk_args
343
+ return orjson.dumps(
344
+ data,
345
+ default=cratedb_json_encoder,
346
+ option=orjson.OPT_PASSTHROUGH_DATETIME,
347
+ )
348
+
349
+
350
+ def _get_socket_opts(
351
+ keepalive=True, tcp_keepidle=None, tcp_keepintvl=None, tcp_keepcnt=None
352
+ ):
353
+ """
354
+ Return an optional list of socket options for urllib3's HTTPConnection
355
+ constructor.
356
+ """
357
+ if not keepalive:
358
+ return None
359
+
360
+ # always use TCP keepalive
361
+ opts = [(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)]
362
+
363
+ # hasattr check because some options depend on system capabilities
364
+ # see https://docs.python.org/3/library/socket.html#socket.SOMAXCONN
365
+ if hasattr(socket, "TCP_KEEPIDLE") and tcp_keepidle is not None:
366
+ opts.append((socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, tcp_keepidle))
367
+ if hasattr(socket, "TCP_KEEPINTVL") and tcp_keepintvl is not None:
368
+ opts.append((socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, tcp_keepintvl))
369
+ if hasattr(socket, "TCP_KEEPCNT") and tcp_keepcnt is not None:
370
+ opts.append((socket.IPPROTO_TCP, socket.TCP_KEEPCNT, tcp_keepcnt))
371
+
372
+ # additionally use urllib3's default socket options
373
+ return list(HTTPConnection.default_socket_options) + opts
374
+
375
+
376
+ class Client:
377
+ """
378
+ Crate connection client using CrateDB's HTTP API.
379
+ """
380
+
381
+ SQL_PATH = "/_sql?types=true"
382
+ """Crate URI path for issuing SQL statements."""
383
+
384
+ retry_interval = 30
385
+ """Retry interval for failed servers in seconds."""
386
+
387
+ default_server = "http://127.0.0.1:4200"
388
+ """Default server to use if no servers are given on instantiation."""
389
+
390
+ def __init__(
391
+ self,
392
+ servers=None,
393
+ timeout=None,
394
+ backoff_factor=0,
395
+ verify_ssl_cert=True,
396
+ ca_cert=None,
397
+ error_trace=False,
398
+ cert_file=None,
399
+ key_file=None,
400
+ ssl_relax_minimum_version=False,
401
+ username=None,
402
+ password=None,
403
+ schema=None,
404
+ pool_size=None,
405
+ socket_keepalive=True,
406
+ socket_tcp_keepidle=None,
407
+ socket_tcp_keepintvl=None,
408
+ socket_tcp_keepcnt=None,
409
+ ):
410
+ if not servers:
411
+ servers = [self.default_server]
412
+ else:
413
+ servers = _to_server_list(servers)
414
+
415
+ # Try to derive credentials from first server argument if not
416
+ # explicitly given.
417
+ if servers and not username:
418
+ try:
419
+ url = urlparse(servers[0])
420
+ if url.username is not None:
421
+ username = url.username
422
+ if url.password is not None:
423
+ password = url.password
424
+ except Exception as ex:
425
+ logger.warning(
426
+ "Unable to decode credentials from database "
427
+ "URI, so connecting to CrateDB without "
428
+ "authentication: {ex}".format(ex=ex)
429
+ )
430
+
431
+ self._active_servers = servers
432
+ self._inactive_servers = []
433
+ pool_kw = _pool_kw_args(
434
+ verify_ssl_cert,
435
+ ca_cert,
436
+ cert_file,
437
+ key_file,
438
+ timeout,
439
+ pool_size,
440
+ )
441
+ pool_kw.update(
442
+ {
443
+ "socket_keepalive": socket_keepalive,
444
+ "socket_tcp_keepidle": socket_tcp_keepidle,
445
+ "socket_tcp_keepintvl": socket_tcp_keepintvl,
446
+ "socket_tcp_keepcnt": socket_tcp_keepcnt,
447
+ }
448
+ )
449
+ self.ssl_relax_minimum_version = ssl_relax_minimum_version
450
+ self.backoff_factor = backoff_factor
451
+ self.server_pool = {}
452
+ self._update_server_pool(servers, **pool_kw)
453
+ self._pool_kw = pool_kw
454
+ self._lock = threading.RLock()
455
+ self._local = threading.local()
456
+ self.username = username
457
+ self.password = password
458
+ self.schema = schema
459
+
460
+ self.path = self.SQL_PATH
461
+ if error_trace:
462
+ self.path += "&error_trace=true"
463
+
464
+ def close(self):
465
+ for server in self.server_pool.values():
466
+ server.close()
467
+
468
+ def _create_server(self, server, **pool_kw):
469
+ kwargs = _remove_certs_for_non_https(server, pool_kw)
470
+ # After updating to urllib3 v2, optionally retain support
471
+ # for TLS 1.0 and TLS 1.1, in order to support connectivity
472
+ # to older versions of CrateDB.
473
+ if self.ssl_relax_minimum_version:
474
+ _update_pool_kwargs_for_ssl_minimum_version(server, kwargs)
475
+ self.server_pool[server] = Server(server, **kwargs)
476
+
477
+ def _update_server_pool(self, servers, **pool_kw):
478
+ for server in servers:
479
+ self._create_server(server, **pool_kw)
480
+
481
+ def sql(self, stmt, parameters=None, bulk_parameters=None):
482
+ """
483
+ Execute SQL stmt against the crate server.
484
+ """
485
+ if stmt is None:
486
+ return None
487
+
488
+ data = _create_sql_payload(stmt, parameters, bulk_parameters)
489
+ logger.debug("Sending request to %s with payload: %s", self.path, data)
490
+ content = self._json_request("POST", self.path, data=data)
491
+ logger.debug("JSON response for stmt(%s): %s", stmt, content)
492
+
493
+ return content
494
+
495
+ def server_infos(self, server):
496
+ response = self._request("GET", "/", server=server)
497
+ _raise_for_status(response)
498
+ content = _json_from_response(response)
499
+ node_name = content.get("name")
500
+ node_version = content.get("version", {}).get("number", "0.0.0")
501
+ return server, node_name, node_version
502
+
503
+ def blob_put(self, table, digest, data) -> bool:
504
+ """
505
+ Stores the contents of the file like @data object in a blob under the
506
+ given table and digest.
507
+ """
508
+ response = self._request("PUT", _blob_path(table, digest), data=data)
509
+ if response.status == 201:
510
+ # blob created
511
+ return True
512
+ if response.status == 409:
513
+ # blob exists
514
+ return False
515
+ if response.status in (400, 404):
516
+ raise BlobLocationNotFoundException(table, digest)
517
+ _raise_for_status(response)
518
+ return False
519
+
520
+ def blob_del(self, table, digest) -> bool:
521
+ """
522
+ Deletes the blob with given digest under the given table.
523
+ """
524
+ response = self._request("DELETE", _blob_path(table, digest))
525
+ if response.status == 204:
526
+ return True
527
+ if response.status == 404:
528
+ return False
529
+ _raise_for_status(response)
530
+ return False
531
+
532
+ def blob_get(self, table, digest, chunk_size=1024 * 128):
533
+ """
534
+ Returns a file like object representing the contents of the blob
535
+ with the given digest.
536
+ """
537
+ response = self._request("GET", _blob_path(table, digest), stream=True)
538
+ if response.status == 404:
539
+ raise DigestNotFoundException(table, digest)
540
+ _raise_for_status(response)
541
+ return response.stream(amt=chunk_size)
542
+
543
+ def blob_exists(self, table, digest) -> bool:
544
+ """
545
+ Returns true if the blob with the given digest exists
546
+ under the given table.
547
+ """
548
+ response = self._request("HEAD", _blob_path(table, digest))
549
+ if response.status == 200:
550
+ return True
551
+ elif response.status == 404:
552
+ return False
553
+ _raise_for_status(response)
554
+ return False
555
+
556
+ def _add_server(self, server):
557
+ with self._lock:
558
+ if server not in self.server_pool:
559
+ self._create_server(server, **self._pool_kw)
560
+
561
+ def _request(self, method, path, server=None, **kwargs):
562
+ """Execute a request to the cluster
563
+
564
+ A server is selected from the server pool.
565
+ """
566
+ while True:
567
+ next_server = server or self._get_server()
568
+ try:
569
+ response = self.server_pool[next_server].request(
570
+ method,
571
+ path,
572
+ username=self.username,
573
+ password=self.password,
574
+ backoff_factor=self.backoff_factor,
575
+ schema=self.schema,
576
+ **kwargs,
577
+ )
578
+ redirect_location = response.get_redirect_location()
579
+ if redirect_location and 300 <= response.status <= 308:
580
+ redirect_server = _server_url(redirect_location)
581
+ self._add_server(redirect_server)
582
+ return self._request(
583
+ method, path, server=redirect_server, **kwargs
584
+ )
585
+ if not server and response.status in SRV_UNAVAILABLE_STATUSES:
586
+ with self._lock:
587
+ # drop server from active ones
588
+ self._drop_server(next_server, response.reason)
589
+ else:
590
+ return response
591
+ except (
592
+ MaxRetryError,
593
+ ReadTimeoutError,
594
+ SSLError,
595
+ HTTPError,
596
+ ProxyError,
597
+ ) as ex:
598
+ ex_message = _ex_to_message(ex)
599
+ if server:
600
+ raise ConnectionError(
601
+ "Server not available, exception: %s" % ex_message
602
+ ) from ex
603
+ preserve_server = False
604
+ if isinstance(ex, ProtocolError):
605
+ preserve_server = any(
606
+ t in [type(arg) for arg in ex.args]
607
+ for t in PRESERVE_ACTIVE_SERVER_EXCEPTIONS
608
+ )
609
+ if not preserve_server:
610
+ with self._lock:
611
+ # drop server from active ones
612
+ self._drop_server(next_server, ex_message)
613
+ except Exception as e:
614
+ raise ProgrammingError(_ex_to_message(e)) from e
615
+
616
+ def _json_request(self, method, path, data):
617
+ """
618
+ Issue request against the crate HTTP API.
619
+ """
620
+
621
+ response = self._request(method, path, data=data)
622
+ _raise_for_status(response)
623
+ if len(response.data) > 0:
624
+ return _json_from_response(response)
625
+ return response.data
626
+
627
+ def _get_server(self):
628
+ """
629
+ Get server to use for request.
630
+ Also process inactive server list, re-add them after given interval.
631
+ """
632
+ with self._lock:
633
+ inactive_server_count = len(self._inactive_servers)
634
+ for _ in range(inactive_server_count):
635
+ try:
636
+ ts, server, message = heapq.heappop(self._inactive_servers)
637
+ except IndexError:
638
+ pass
639
+ else:
640
+ if (ts + self.retry_interval) > time():
641
+ # Not yet, put it back
642
+ heapq.heappush(
643
+ self._inactive_servers, (ts, server, message)
644
+ )
645
+ else:
646
+ self._active_servers.append(server)
647
+ logger.warning(
648
+ "Restored server %s into active pool", server
649
+ )
650
+
651
+ # if none is old enough, use oldest
652
+ if not self._active_servers:
653
+ ts, server, message = heapq.heappop(self._inactive_servers)
654
+ self._active_servers.append(server)
655
+ logger.info("Restored server %s into active pool", server)
656
+
657
+ server = self._active_servers[0]
658
+ self._roundrobin()
659
+
660
+ return server
661
+
662
+ @property
663
+ def active_servers(self):
664
+ """get the active servers for this client"""
665
+ with self._lock:
666
+ return list(self._active_servers)
667
+
668
+ def _drop_server(self, server, message):
669
+ """
670
+ Drop server from active list and adds it to the inactive ones.
671
+ """
672
+ try:
673
+ self._active_servers.remove(server)
674
+ except ValueError:
675
+ pass
676
+ else:
677
+ heapq.heappush(self._inactive_servers, (time(), server, message))
678
+ logger.warning("Removed server %s from active pool", server)
679
+
680
+ # if this is the last server raise exception, otherwise try next
681
+ if not self._active_servers:
682
+ raise ConnectionError(
683
+ ("No more Servers available, exception from last server: %s")
684
+ % message
685
+ )
686
+
687
+ def _roundrobin(self):
688
+ """
689
+ Very simple round-robin implementation
690
+ """
691
+ self._active_servers.append(self._active_servers.pop(0))
692
+
693
+ def __repr__(self):
694
+ return "<Client {0}>".format(str(self._active_servers))
File without changes