launchdarkly-eventsource 1.5.1__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.
- launchdarkly_eventsource-1.5.1.dist-info/METADATA +67 -0
- launchdarkly_eventsource-1.5.1.dist-info/RECORD +26 -0
- launchdarkly_eventsource-1.5.1.dist-info/WHEEL +4 -0
- launchdarkly_eventsource-1.5.1.dist-info/licenses/LICENSE +13 -0
- ld_eventsource/__init__.py +1 -0
- ld_eventsource/actions.py +176 -0
- ld_eventsource/config/__init__.py +4 -0
- ld_eventsource/config/connect_strategy.py +162 -0
- ld_eventsource/config/error_strategy.py +143 -0
- ld_eventsource/config/retry_delay_strategy.py +154 -0
- ld_eventsource/errors.py +62 -0
- ld_eventsource/http.py +128 -0
- ld_eventsource/reader.py +117 -0
- ld_eventsource/sse_client.py +328 -0
- ld_eventsource/testing/__init__.py +0 -0
- ld_eventsource/testing/helpers.py +84 -0
- ld_eventsource/testing/http_util.py +216 -0
- ld_eventsource/testing/test_error_strategy.py +49 -0
- ld_eventsource/testing/test_headers.py +215 -0
- ld_eventsource/testing/test_http_connect_strategy.py +241 -0
- ld_eventsource/testing/test_http_connect_strategy_with_sse_client.py +122 -0
- ld_eventsource/testing/test_reader.py +128 -0
- ld_eventsource/testing/test_retry_delay_strategy.py +102 -0
- ld_eventsource/testing/test_sse_client_basic.py +94 -0
- ld_eventsource/testing/test_sse_client_retry.py +190 -0
- ld_eventsource/version.py +1 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: launchdarkly-eventsource
|
|
3
|
+
Version: 1.5.1
|
|
4
|
+
Summary: LaunchDarkly SSE Client
|
|
5
|
+
License: Apache-2.0
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Author: LaunchDarkly
|
|
8
|
+
Author-email: dev@launchdarkly.com
|
|
9
|
+
Requires-Python: >=3.9
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Topic :: Software Development
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
22
|
+
Requires-Dist: urllib3 (>=1.26.0,<3)
|
|
23
|
+
Project-URL: Documentation, https://launchdarkly-sse-client-library.readthedocs.io/en/latest/
|
|
24
|
+
Project-URL: Homepage, https://docs.launchdarkly.com/sdk/server-side/python
|
|
25
|
+
Project-URL: Repository, https://github.com/launchdarkly/python-eventsource
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# LaunchDarkly SSE Client for Python
|
|
29
|
+
|
|
30
|
+
[](https://github.com/launchdarkly/python-eventsource/actions/workflows/ci.yml)
|
|
31
|
+
[](https://pypi.python.org/pypi/launchdarkly-eventsource)
|
|
32
|
+
[](https://launchdarkly-sse-client-library.readthedocs.io/en/latest/)
|
|
33
|
+
|
|
34
|
+
## Overview
|
|
35
|
+
|
|
36
|
+
The `launchdarkly/python-eventsource` package allows Python developers to consume Server-Sent-Events (SSE) from a remote API. The SSE specification is defined here: [https://html.spec.whatwg.org/multipage/server-sent-events.html](https://html.spec.whatwg.org/multipage/server-sent-events.html#server-sent-events)
|
|
37
|
+
|
|
38
|
+
This package's primary purpose is to support the [LaunchDarkly SDK for Python](https://github.com/launchdarkly/python-server-sdk), but it can be used independently. In its simplest configuration, it emulates the behavior of the EventSource API as defined in the SSE specification, with the addition of exponential backoff behavior for retries. However, it also includes optional features used by LaunchDarkly SDKs that are not part of the core specification, such as:
|
|
39
|
+
|
|
40
|
+
* Customizing the backoff/jitter behavior.
|
|
41
|
+
* Setting read timeouts, custom headers, and other HTTP request properties.
|
|
42
|
+
* Specifying that connections should be retried under circumstances where the standard EventSource behavior would not retry them, such as if the server returns an HTTP error status.
|
|
43
|
+
|
|
44
|
+
This is a synchronous implementation which blocks the caller's thread when reading events or reconnecting. By default, it uses `urllib3` to make HTTP requests, but it can be configured to read any input stream.
|
|
45
|
+
|
|
46
|
+
## Supported Python versions
|
|
47
|
+
|
|
48
|
+
This version of the package is compatible with Python 3.9 and higher.
|
|
49
|
+
|
|
50
|
+
## Contributing
|
|
51
|
+
|
|
52
|
+
We encourage pull requests and other contributions from the community. Check out our [contributing guidelines](CONTRIBUTING.md) for instructions on how to contribute to this SDK.
|
|
53
|
+
|
|
54
|
+
## About LaunchDarkly
|
|
55
|
+
|
|
56
|
+
* LaunchDarkly is a continuous delivery platform that provides feature flags as a service and allows developers to iterate quickly and safely. We allow you to easily flag your features and manage them from the LaunchDarkly dashboard. With LaunchDarkly, you can:
|
|
57
|
+
* Roll out a new feature to a subset of your users (like a group of users who opt-in to a beta tester group), gathering feedback and bug reports from real-world use cases.
|
|
58
|
+
* Gradually roll out a feature to an increasing percentage of users, and track the effect that the feature has on key metrics (for instance, how likely is a user to complete a purchase if they have feature A versus feature B?).
|
|
59
|
+
* Turn off a feature that you realize is causing performance problems in production, without needing to re-deploy, or even restart the application with a changed configuration file.
|
|
60
|
+
* Grant access to certain features based on user attributes, like payment plan (eg: users on the ‘gold’ plan get access to more features than users in the ‘silver’ plan). Disable parts of your application to facilitate maintenance, without taking everything offline.
|
|
61
|
+
* LaunchDarkly provides feature flag SDKs for a wide variety of languages and technologies. Read [our documentation](https://docs.launchdarkly.com/sdk) for a complete list.
|
|
62
|
+
* Explore LaunchDarkly
|
|
63
|
+
* [launchdarkly.com](https://www.launchdarkly.com/ "LaunchDarkly Main Website") for more information
|
|
64
|
+
* [docs.launchdarkly.com](https://docs.launchdarkly.com/ "LaunchDarkly Documentation") for our documentation and SDK reference guides
|
|
65
|
+
* [apidocs.launchdarkly.com](https://apidocs.launchdarkly.com/ "LaunchDarkly API Documentation") for our API documentation
|
|
66
|
+
* [blog.launchdarkly.com](https://blog.launchdarkly.com/ "LaunchDarkly Blog Documentation") for the latest product updates
|
|
67
|
+
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
ld_eventsource/__init__.py,sha256=Rj-tMSwF3Ca2ZBgVrGVK0FXbf9w_MV-Xcl4KN_ES_oQ,40
|
|
2
|
+
ld_eventsource/actions.py,sha256=Ulq35iWycziB25AXiX5upXWuiH4XVtJRRsOMwHWbXtM,5573
|
|
3
|
+
ld_eventsource/config/__init__.py,sha256=u2RNWRh8CFGLChAuYGmkyRBxAAHCA93FPC9rZJjCTh8,210
|
|
4
|
+
ld_eventsource/config/connect_strategy.py,sha256=gwv3c3TKJ7Lr2PjEdcDOCIyIIlF5vibNOzkLkCmoftk,5538
|
|
5
|
+
ld_eventsource/config/error_strategy.py,sha256=WQn41yVm4Aej_0poAQNi20Eky2z19U_71r0cVL8UVnc,5989
|
|
6
|
+
ld_eventsource/config/retry_delay_strategy.py,sha256=CzSBD00GjVoMGAN97Y88rOUoEc8ngNafklaOWiVQOuU,6041
|
|
7
|
+
ld_eventsource/errors.py,sha256=FN4X_ElLnQvasnojg6iWVFdKmmWkPXkZattQdhaL2qw,1903
|
|
8
|
+
ld_eventsource/http.py,sha256=vMw_CPBDWpt-4C5Sn5KjkTIIhaJU8rQ8wubXUoboPpk,4173
|
|
9
|
+
ld_eventsource/reader.py,sha256=i4TOH_HF5RpZO1dL3BXrTNtY7ns-w7ESDyQawhfsBUw,4591
|
|
10
|
+
ld_eventsource/sse_client.py,sha256=9L-Od6r5QscLIf-znGtMgZbFTFr4SEDFfT_rdWZ22QI,15424
|
|
11
|
+
ld_eventsource/testing/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
|
+
ld_eventsource/testing/helpers.py,sha256=PnBjZDuJFzyVNWjMQe7q4vh_eTVAas_XRiJ34p-22RE,2583
|
|
13
|
+
ld_eventsource/testing/http_util.py,sha256=g4CDbmnN07v35mwmbWdsm3NO9DmcC0couuSil_96FRA,6181
|
|
14
|
+
ld_eventsource/testing/test_error_strategy.py,sha256=wGOvb5a_qCEmkX34urYLDwoaQB-I637kjGH775IsHDs,1206
|
|
15
|
+
ld_eventsource/testing/test_headers.py,sha256=DlNhbJzxzKQAa55h-yPmwsREdjj63joyjtvbKgHkvLw,7245
|
|
16
|
+
ld_eventsource/testing/test_http_connect_strategy.py,sha256=YOSnZp7H2-ZgE0sqiCZ3eLfYrJZ_eTsxkPdvxoSfEhI,9836
|
|
17
|
+
ld_eventsource/testing/test_http_connect_strategy_with_sse_client.py,sha256=0jRGnkbA7elgPkyysddw7OAQl2KAZf6gGRNhHvGET_M,5098
|
|
18
|
+
ld_eventsource/testing/test_reader.py,sha256=IzQcfY2617YQh09Lhdb7z3IVQwJ72DRGTOP47sdF8wI,4261
|
|
19
|
+
ld_eventsource/testing/test_retry_delay_strategy.py,sha256=kC8drzR5dhqSVyCpYMvZqOvMIzWHw_1cyOUOSCJDi1Q,2932
|
|
20
|
+
ld_eventsource/testing/test_sse_client_basic.py,sha256=kyq71hPwprytgi35xzIDoDQBcbHzXekSR-zxG8E7-uM,2853
|
|
21
|
+
ld_eventsource/testing/test_sse_client_retry.py,sha256=q-P-mOtfWVavQQ5BjOUExJmsUUsl1Oz9riznlALqd6o,5684
|
|
22
|
+
ld_eventsource/version.py,sha256=Y4RerTQOBLCwfc5jQyTzIRJmt11UdnzYO783pXhWZ7Y,46
|
|
23
|
+
launchdarkly_eventsource-1.5.1.dist-info/METADATA,sha256=KbaCiGfQ4N3PEIGHqEfFKPvyOz57fixLO-rGyhzIdCI,5126
|
|
24
|
+
launchdarkly_eventsource-1.5.1.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
|
|
25
|
+
launchdarkly_eventsource-1.5.1.dist-info/licenses/LICENSE,sha256=LNd6xXgmOpKMYnrqSSyhw2lTAfKVet-9Qd15tQbpzC8,557
|
|
26
|
+
launchdarkly_eventsource-1.5.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Copyright 2021 Catamorphic, Co.
|
|
2
|
+
|
|
3
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
you may not use this file except in compliance with the License.
|
|
5
|
+
You may obtain a copy of the License at
|
|
6
|
+
|
|
7
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
|
|
9
|
+
Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
See the License for the specific language governing permissions and
|
|
13
|
+
limitations under the License.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from ld_eventsource.sse_client import *
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import Any, Dict, Optional
|
|
3
|
+
|
|
4
|
+
from ld_eventsource.errors import ExceptionWithHeaders
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Action:
|
|
8
|
+
"""
|
|
9
|
+
Base class for objects that can be returned by :attr:`.SSEClient.all`.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Event(Action):
|
|
16
|
+
"""
|
|
17
|
+
An event received by :class:`.SSEClient`.
|
|
18
|
+
|
|
19
|
+
Instances of this class are returned by both :attr:`.SSEClient.events` and
|
|
20
|
+
:attr:`.SSEClient.all`.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
event: str = 'message',
|
|
26
|
+
data: str = '',
|
|
27
|
+
id: Optional[str] = None,
|
|
28
|
+
last_event_id: Optional[str] = None,
|
|
29
|
+
):
|
|
30
|
+
self._event = event
|
|
31
|
+
self._data = data
|
|
32
|
+
self._id = id
|
|
33
|
+
self._last_event_id = last_event_id
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def event(self) -> str:
|
|
37
|
+
"""
|
|
38
|
+
The event type, or "message" if not specified.
|
|
39
|
+
"""
|
|
40
|
+
return self._event
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def data(self) -> str:
|
|
44
|
+
"""
|
|
45
|
+
The event data.
|
|
46
|
+
"""
|
|
47
|
+
return self._data
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def id(self) -> Optional[str]:
|
|
51
|
+
"""
|
|
52
|
+
The value of the ``id:`` field for this event, or `None` if omitted.
|
|
53
|
+
"""
|
|
54
|
+
return self._id
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def last_event_id(self) -> Optional[str]:
|
|
58
|
+
"""
|
|
59
|
+
The value of the most recent ``id:`` field of an event seen in this stream so far.
|
|
60
|
+
"""
|
|
61
|
+
return self._last_event_id
|
|
62
|
+
|
|
63
|
+
def __eq__(self, other):
|
|
64
|
+
if not isinstance(other, Event):
|
|
65
|
+
return False
|
|
66
|
+
return (
|
|
67
|
+
self._event == other._event
|
|
68
|
+
and self._data == other._data
|
|
69
|
+
and self._id == other._id
|
|
70
|
+
and self.last_event_id == other.last_event_id
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
def __repr__(self):
|
|
74
|
+
return "Event(event=\"%s\", data=%s, id=%s, last_event_id=%s)" % (
|
|
75
|
+
self._event,
|
|
76
|
+
json.dumps(self._data),
|
|
77
|
+
"None" if self._id is None else json.dumps(self._id),
|
|
78
|
+
"None" if self._last_event_id is None else json.dumps(self._last_event_id),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class Comment(Action):
|
|
83
|
+
"""
|
|
84
|
+
A comment received by :class:`.SSEClient`.
|
|
85
|
+
|
|
86
|
+
Comment lines (any line beginning with a colon) have no significance in the SSE specification
|
|
87
|
+
and can be ignored, but if you want to see them, use :attr:`.SSEClient.all`. They will never
|
|
88
|
+
be returned by :attr:`.SSEClient.events`.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
def __init__(self, comment: str):
|
|
92
|
+
self._comment = comment
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def comment(self) -> str:
|
|
96
|
+
"""
|
|
97
|
+
The comment text, not including the leading colon.
|
|
98
|
+
"""
|
|
99
|
+
return self._comment
|
|
100
|
+
|
|
101
|
+
def __eq__(self, other):
|
|
102
|
+
return isinstance(other, Comment) and self._comment == other._comment
|
|
103
|
+
|
|
104
|
+
def __repr__(self):
|
|
105
|
+
return ":" + self._comment
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class Start(Action):
|
|
109
|
+
"""
|
|
110
|
+
Indicates that :class:`.SSEClient` has successfully connected to a stream.
|
|
111
|
+
|
|
112
|
+
Instances of this class are only available from :attr:`.SSEClient.all`.
|
|
113
|
+
A ``Start`` is returned for the first successful connection. If the client reconnects
|
|
114
|
+
after a failure, there will be a :class:`.Fault` followed by a ``Start``.
|
|
115
|
+
|
|
116
|
+
Each ``Start`` action may include HTTP response headers from the connection. These headers
|
|
117
|
+
are available via the :attr:`headers` property. On reconnection, a new ``Start`` will be
|
|
118
|
+
emitted with the headers from the new connection, which may differ from the previous one.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
def __init__(self, headers: Optional[Dict[str, Any]] = None):
|
|
122
|
+
self._headers = headers
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def headers(self) -> Optional[Dict[str, Any]]:
|
|
126
|
+
"""
|
|
127
|
+
The HTTP response headers from the stream connection, if available.
|
|
128
|
+
|
|
129
|
+
The headers dict uses case-insensitive keys (via urllib3's HTTPHeaderDict).
|
|
130
|
+
|
|
131
|
+
:return: the response headers, or ``None`` if not available
|
|
132
|
+
"""
|
|
133
|
+
return self._headers
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class Fault(Action):
|
|
137
|
+
"""
|
|
138
|
+
Indicates that :class:`.SSEClient` encountered an error or end of stream.
|
|
139
|
+
|
|
140
|
+
Instances of this class are only available from :attr:`.SSEClient.all`.
|
|
141
|
+
|
|
142
|
+
If you receive a Fault, the SSEClient is now in an inactive state since either a
|
|
143
|
+
connection attempt has failed or an existing connection has been closed. The SSEClient
|
|
144
|
+
will attempt to reconnect if you either call :meth:`.SSEClient.start()`
|
|
145
|
+
or simply continue reading events after this point.
|
|
146
|
+
|
|
147
|
+
When the error includes HTTP response headers (such as for :class:`.HTTPStatusError`
|
|
148
|
+
or :class:`.HTTPContentTypeError`), they are accessible via the :attr:`headers` property.
|
|
149
|
+
"""
|
|
150
|
+
|
|
151
|
+
def __init__(self, error: Optional[Exception]):
|
|
152
|
+
self.__error = error
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def error(self) -> Optional[Exception]:
|
|
156
|
+
"""
|
|
157
|
+
The exception that caused the stream to fail, if any. If this is ``None``, it means
|
|
158
|
+
that the stream simply ran out of data, i.e. the server shut down the connection
|
|
159
|
+
in an orderly way after sending an EOF chunk as defined by chunked transfer encoding.
|
|
160
|
+
"""
|
|
161
|
+
return self.__error
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def headers(self) -> Optional[Dict[str, Any]]:
|
|
165
|
+
"""
|
|
166
|
+
The HTTP response headers from the failed connection, if available.
|
|
167
|
+
|
|
168
|
+
This property returns headers when the error is an exception that includes them,
|
|
169
|
+
such as :class:`.HTTPStatusError` or :class:`.HTTPContentTypeError`. For other
|
|
170
|
+
error types or when the stream ended normally, this returns ``None``.
|
|
171
|
+
|
|
172
|
+
:return: the response headers, or ``None`` if not available
|
|
173
|
+
"""
|
|
174
|
+
if isinstance(self.__error, ExceptionWithHeaders):
|
|
175
|
+
return self.__error.headers
|
|
176
|
+
return None
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from logging import Logger
|
|
5
|
+
from typing import Any, Callable, Dict, Iterator, Optional, Union
|
|
6
|
+
|
|
7
|
+
from urllib3 import PoolManager
|
|
8
|
+
|
|
9
|
+
from ld_eventsource.http import (DynamicQueryParams, _HttpClientImpl,
|
|
10
|
+
_HttpConnectParams)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ConnectStrategy:
|
|
14
|
+
"""
|
|
15
|
+
An abstraction for how :class:`.SSEClient` should obtain an input stream.
|
|
16
|
+
|
|
17
|
+
The default implementation is :meth:`http()`, which makes HTTP requests with ``urllib3``.
|
|
18
|
+
Or, if you want to consume an input stream from some other source, you can create your own
|
|
19
|
+
subclass of :class:`ConnectStrategy`.
|
|
20
|
+
|
|
21
|
+
Instances of this class should be immutable and should not contain any state that is specific
|
|
22
|
+
to one active stream. The :class:`ConnectionClient` that they produce is stateful and belongs
|
|
23
|
+
to a single :class:`.SSEClient`.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def create_client(self, logger: Logger) -> ConnectionClient:
|
|
27
|
+
"""
|
|
28
|
+
Creates a client instance.
|
|
29
|
+
|
|
30
|
+
This is called once when an :class:`.SSEClient` is created. The SSEClient returns the
|
|
31
|
+
returned :class:`ConnectionClient` and uses it to perform all subsequent connection attempts.
|
|
32
|
+
|
|
33
|
+
:param logger: the logger being used by the SSEClient
|
|
34
|
+
"""
|
|
35
|
+
raise NotImplementedError("ConnectStrategy base class cannot be used by itself")
|
|
36
|
+
|
|
37
|
+
@staticmethod
|
|
38
|
+
def http(
|
|
39
|
+
url: str,
|
|
40
|
+
headers: Optional[dict] = None,
|
|
41
|
+
pool: Optional[PoolManager] = None,
|
|
42
|
+
urllib3_request_options: Optional[dict] = None,
|
|
43
|
+
query_params: Optional[DynamicQueryParams] = None
|
|
44
|
+
) -> ConnectStrategy:
|
|
45
|
+
"""
|
|
46
|
+
Creates the default HTTP implementation, specifying request parameters.
|
|
47
|
+
|
|
48
|
+
:param url: the stream URL
|
|
49
|
+
:param headers: optional HTTP headers to add to the request
|
|
50
|
+
:param pool: optional urllib3 ``PoolManager`` to provide an HTTP client
|
|
51
|
+
:param urllib3_request_options: optional ``kwargs`` to add to the ``request`` call; these
|
|
52
|
+
can include any parameters supported by ``urllib3``, such as ``timeout``
|
|
53
|
+
:param query_params: optional callable that can be used to affect query parameters
|
|
54
|
+
dynamically for each connection attempt
|
|
55
|
+
"""
|
|
56
|
+
return _HttpConnectStrategy(
|
|
57
|
+
_HttpConnectParams(url, headers, pool, urllib3_request_options, query_params)
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ConnectionClient:
|
|
62
|
+
"""
|
|
63
|
+
An object provided by :class:`.ConnectStrategy` that is retained by a single
|
|
64
|
+
:class:`.SSEClient` to perform all connection attempts by that instance.
|
|
65
|
+
|
|
66
|
+
For the default HTTP implementation, this represents an HTTP connection pool.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def connect(self, last_event_id: Optional[str]) -> ConnectionResult:
|
|
70
|
+
"""
|
|
71
|
+
Attempts to connect to a stream. Raises an exception if unsuccessful.
|
|
72
|
+
|
|
73
|
+
:param last_event_id: the current value of :attr:`SSEClient.last_event_id`
|
|
74
|
+
(should be sent to the server to support resuming an interrupted stream)
|
|
75
|
+
:return: a :class:`ConnectionResult` representing the stream
|
|
76
|
+
"""
|
|
77
|
+
raise NotImplementedError(
|
|
78
|
+
"ConnectionClient base class cannot be used by itself"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
def close(self):
|
|
82
|
+
"""
|
|
83
|
+
Does whatever is necessary to release resources when the SSEClient is closed.
|
|
84
|
+
"""
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
def __enter__(self):
|
|
88
|
+
return self
|
|
89
|
+
|
|
90
|
+
def __exit__(self, type, value, traceback):
|
|
91
|
+
self.close()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class ConnectionResult:
|
|
95
|
+
"""
|
|
96
|
+
The return type of :meth:`ConnectionClient.connect()`.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
def __init__(self, stream: Iterator[bytes], closer: Optional[Callable], headers: Optional[Dict[str, Any]] = None):
|
|
100
|
+
self.__stream = stream
|
|
101
|
+
self.__closer = closer
|
|
102
|
+
self.__headers = headers
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def stream(self) -> Iterator[bytes]:
|
|
106
|
+
"""
|
|
107
|
+
An iterator that returns chunks of data.
|
|
108
|
+
"""
|
|
109
|
+
return self.__stream
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def headers(self) -> Optional[Dict[str, Any]]:
|
|
113
|
+
"""
|
|
114
|
+
The HTTP response headers, if available.
|
|
115
|
+
|
|
116
|
+
For HTTP connections, this contains the headers from the SSE stream response.
|
|
117
|
+
For non-HTTP connections, this will be ``None``.
|
|
118
|
+
|
|
119
|
+
The headers dict uses case-insensitive keys (via urllib3's HTTPHeaderDict).
|
|
120
|
+
"""
|
|
121
|
+
return self.__headers
|
|
122
|
+
|
|
123
|
+
def close(self):
|
|
124
|
+
"""
|
|
125
|
+
Does whatever is necessary to release the connection.
|
|
126
|
+
"""
|
|
127
|
+
if self.__closer:
|
|
128
|
+
self.__closer()
|
|
129
|
+
self.__closer = None
|
|
130
|
+
|
|
131
|
+
def __enter__(self):
|
|
132
|
+
return self
|
|
133
|
+
|
|
134
|
+
def __exit__(self, type, value, traceback):
|
|
135
|
+
self.close()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# _HttpConnectStrategy and _HttpConnectionClient are defined here rather than in http.py to avoid
|
|
139
|
+
# a circular module reference.
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class _HttpConnectStrategy(ConnectStrategy):
|
|
143
|
+
def __init__(self, params: _HttpConnectParams):
|
|
144
|
+
self.__params = params
|
|
145
|
+
|
|
146
|
+
def create_client(self, logger: Logger) -> ConnectionClient:
|
|
147
|
+
return _HttpConnectionClient(self.__params, logger)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class _HttpConnectionClient(ConnectionClient):
|
|
151
|
+
def __init__(self, params: _HttpConnectParams, logger: Logger):
|
|
152
|
+
self.__impl = _HttpClientImpl(params, logger)
|
|
153
|
+
|
|
154
|
+
def connect(self, last_event_id: Optional[str]) -> ConnectionResult:
|
|
155
|
+
stream, closer, headers = self.__impl.connect(last_event_id)
|
|
156
|
+
return ConnectionResult(stream, closer, headers)
|
|
157
|
+
|
|
158
|
+
def close(self):
|
|
159
|
+
self.__impl.close()
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
__all__ = ['ConnectStrategy', 'ConnectionClient', 'ConnectionResult']
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import Callable, Optional, Tuple
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ErrorStrategy:
|
|
8
|
+
"""
|
|
9
|
+
Base class of strategies for determining how SSEClient should handle a stream error or the
|
|
10
|
+
end of a stream.
|
|
11
|
+
|
|
12
|
+
The parameter that SSEClient passes to :meth:`apply()` is either ``None`` if the server ended
|
|
13
|
+
the stream normally, or an exception. If it is an exception, it could be an I/O exception
|
|
14
|
+
(failure to connect, broken connection, etc.), or one of the error types defined in this
|
|
15
|
+
package such as :class:`.HTTPStatusError`.
|
|
16
|
+
|
|
17
|
+
The two options for the result are:
|
|
18
|
+
|
|
19
|
+
* :const:`FAIL`: This means that SSEClient should throw an exception to the caller-- or, in
|
|
20
|
+
the case of a stream ending without an error, it should simply stop iterating through events.
|
|
21
|
+
* :const:`CONTINUE`: This means that you intend to keep reading events, so SSEClient should
|
|
22
|
+
transparently retry the connection. If you are reading from :attr:`.SSEClient.all`,
|
|
23
|
+
you will also receive a :class:`.Fault` describing the error.
|
|
24
|
+
|
|
25
|
+
With either option, it is still always possible to explicitly reconnect the stream by calling
|
|
26
|
+
:meth:`.SSEClient.start()` again, or simply by trying to read from :attr:`.SSEClient.events`
|
|
27
|
+
or :attr:`.SSEClient.all` again.
|
|
28
|
+
|
|
29
|
+
Subclasses should be immutable. To implement strategies that behave differently on consecutive
|
|
30
|
+
retries, the strategy should return a new instance of its own class as the second return value
|
|
31
|
+
from ``apply``, rather than modifying the state of the existing instance. This makes it easy
|
|
32
|
+
for SSEClient to reset to the original error-handling state when appropriate by simply reusing
|
|
33
|
+
the original instance.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
FAIL = True
|
|
37
|
+
CONTINUE = False
|
|
38
|
+
|
|
39
|
+
def apply(self, exception: Optional[Exception]) -> Tuple[bool, ErrorStrategy]:
|
|
40
|
+
"""Applies the strategy to determine what to do after a failure.
|
|
41
|
+
|
|
42
|
+
:param exception: an exception, or ``None`` if the stream simply ended
|
|
43
|
+
:return: a tuple where the first element is either :const:`FAIL` to raise an exception
|
|
44
|
+
or :const:`CONTINUE` to continue, and the second element is the strategy object to
|
|
45
|
+
use next time (which could be ``self``)
|
|
46
|
+
"""
|
|
47
|
+
raise NotImplementedError("ErrorStrategy base class cannot be used by itself")
|
|
48
|
+
|
|
49
|
+
@staticmethod
|
|
50
|
+
def always_fail() -> ErrorStrategy:
|
|
51
|
+
"""
|
|
52
|
+
Specifies that SSEClient should always treat an error as a stream failure. This is the
|
|
53
|
+
default behavior if you do not configure another.
|
|
54
|
+
"""
|
|
55
|
+
return _LambdaErrorStrategy(lambda e: (ErrorStrategy.FAIL, None))
|
|
56
|
+
|
|
57
|
+
@staticmethod
|
|
58
|
+
def always_continue() -> ErrorStrategy:
|
|
59
|
+
"""
|
|
60
|
+
Specifies that SSEClient should never raise an exception, but should transparently retry
|
|
61
|
+
or, if :attr:`.SSEClient.all` is being used, return the error as a :class:`.Fault`.
|
|
62
|
+
|
|
63
|
+
Be aware that using this mode could cause connection attempts to block indefinitely if
|
|
64
|
+
the server is unavailable.
|
|
65
|
+
"""
|
|
66
|
+
return _LambdaErrorStrategy(lambda e: (ErrorStrategy.CONTINUE, None))
|
|
67
|
+
|
|
68
|
+
@staticmethod
|
|
69
|
+
def continue_with_max_attempts(max_attempts: int) -> ErrorStrategy:
|
|
70
|
+
"""
|
|
71
|
+
Specifies that SSEClient should automatically retry after an error for up to this
|
|
72
|
+
number of consecutive attempts, but should fail after that point.
|
|
73
|
+
|
|
74
|
+
:param max_attempts: the maximum number of consecutive retries
|
|
75
|
+
"""
|
|
76
|
+
return _MaxAttemptsErrorStrategy(max_attempts, 0)
|
|
77
|
+
|
|
78
|
+
@staticmethod
|
|
79
|
+
def continue_with_time_limit(max_time: float) -> ErrorStrategy:
|
|
80
|
+
"""
|
|
81
|
+
Specifies that SSEClient should automatically retry after a failure and can retry
|
|
82
|
+
repeatedly until this amount of time has elapsed, but should fail after that point.
|
|
83
|
+
|
|
84
|
+
:param max_time: the time limit, in seconds
|
|
85
|
+
"""
|
|
86
|
+
return _TimeLimitErrorStrategy(max_time, 0)
|
|
87
|
+
|
|
88
|
+
@staticmethod
|
|
89
|
+
def from_lambda(
|
|
90
|
+
fn: Callable[[Optional[Exception]], Tuple[bool, Optional[ErrorStrategy]]]
|
|
91
|
+
) -> ErrorStrategy:
|
|
92
|
+
"""
|
|
93
|
+
Convenience method for creating an ErrorStrategy whose ``apply`` method is equivalent to
|
|
94
|
+
the given lambda.
|
|
95
|
+
|
|
96
|
+
The one difference is that the second return value is an ``Optional[ErrorStrategy]`` which
|
|
97
|
+
can be None to mean "no change", since the lambda cannot reference the strategy's ``self``.
|
|
98
|
+
"""
|
|
99
|
+
return _LambdaErrorStrategy(fn)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class _LambdaErrorStrategy(ErrorStrategy):
|
|
103
|
+
def __init__(
|
|
104
|
+
self, fn: Callable[[Optional[Exception]], Tuple[bool, Optional[ErrorStrategy]]]
|
|
105
|
+
):
|
|
106
|
+
self.__fn = fn
|
|
107
|
+
|
|
108
|
+
def apply(self, exception: Optional[Exception]) -> Tuple[bool, ErrorStrategy]:
|
|
109
|
+
should_raise, maybe_next = self.__fn(exception)
|
|
110
|
+
return (should_raise, maybe_next or self)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class _MaxAttemptsErrorStrategy(ErrorStrategy):
|
|
114
|
+
def __init__(self, max_attempts: int, counter: int):
|
|
115
|
+
self.__max_attempts = max_attempts
|
|
116
|
+
self.__counter = counter
|
|
117
|
+
|
|
118
|
+
def apply(self, exception: Optional[Exception]) -> Tuple[bool, ErrorStrategy]:
|
|
119
|
+
if self.__counter >= self.__max_attempts:
|
|
120
|
+
return (ErrorStrategy.FAIL, self)
|
|
121
|
+
return (
|
|
122
|
+
ErrorStrategy.CONTINUE,
|
|
123
|
+
_MaxAttemptsErrorStrategy(self.__max_attempts, self.__counter + 1),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class _TimeLimitErrorStrategy(ErrorStrategy):
|
|
128
|
+
def __init__(self, max_time: float, start_time: float):
|
|
129
|
+
self.__max_time = max_time
|
|
130
|
+
self.__start_time = start_time
|
|
131
|
+
|
|
132
|
+
def apply(self, exception: Optional[Exception]) -> Tuple[bool, ErrorStrategy]:
|
|
133
|
+
if self.__start_time == 0:
|
|
134
|
+
return (
|
|
135
|
+
ErrorStrategy.CONTINUE,
|
|
136
|
+
_TimeLimitErrorStrategy(self.__max_time, time.time()),
|
|
137
|
+
)
|
|
138
|
+
if (time.time() - self.__start_time) < self.__max_time:
|
|
139
|
+
return (ErrorStrategy.CONTINUE, self)
|
|
140
|
+
return (ErrorStrategy.FAIL, self)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
__all__ = ['ErrorStrategy']
|