getstream 4.0.0__tar.gz → 4.2.0__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.
- {getstream-4.0.0 → getstream-4.2.0}/CHANGELOG.md +23 -1
- {getstream-4.0.0 → getstream-4.2.0}/PKG-INFO +26 -1
- {getstream-4.0.0 → getstream-4.2.0}/README.md +25 -0
- getstream-4.2.0/getstream/__init__.py +16 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/base.py +255 -18
- {getstream-4.0.0 → getstream-4.2.0}/getstream/config.py +20 -0
- getstream-4.2.0/getstream/logging_utils.py +21 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/stream.py +42 -4
- getstream-4.2.0/getstream/video/rtc/audio_track.py +181 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/track_util.py +100 -1
- {getstream-4.0.0 → getstream-4.2.0}/pyproject.toml +1 -1
- {getstream-4.0.0 → getstream-4.2.0}/uv.lock +0 -4
- getstream-4.0.0/getstream/__init__.py +0 -9
- getstream-4.0.0/getstream/utils/retry.py +0 -71
- getstream-4.0.0/getstream/video/rtc/audio_track.py +0 -257
- {getstream-4.0.0 → getstream-4.2.0}/.cursor/worktrees.json +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/.env.example +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/.github/actions/python-uv-setup/action.yml +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/.github/workflows/ci.yml +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/.github/workflows/release.yml +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/.github/workflows/run_tests.yml +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/.github/workflows/stream-py.code-workspace +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/.gitignore +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/.gitmodules +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/.pre-commit-config.yaml +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/AGENTS.md +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/DEVELOPMENT.md +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/LICENSE.md +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/MIGRATION_v2_to_v3.md +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/Makefile +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/dev.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/docs/migration-from-stream-chat-python/01-setup-and-auth.md +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/docs/migration-from-stream-chat-python/02-users.md +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/docs/migration-from-stream-chat-python/03-channels.md +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/docs/migration-from-stream-chat-python/04-messages-and-reactions.md +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/docs/migration-from-stream-chat-python/05-moderation.md +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/docs/migration-from-stream-chat-python/06-devices.md +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/docs/migration-from-stream-chat-python/README.md +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/generate.sh +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/generate_webrtc.sh +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/chat/__init__.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/chat/async_channel.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/chat/async_client.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/chat/async_rest_client.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/chat/channel.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/chat/client.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/chat/rest_client.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/common/__init__.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/common/async_client.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/common/async_rest_client.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/common/client.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/common/rest_client.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/common/telemetry.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/exceptions.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/feeds/__init__.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/feeds/client.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/feeds/feeds.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/feeds/rest_client.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/generic.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/meta.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/models/__init__.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/moderation/__init__.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/moderation/async_client.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/moderation/async_rest_client.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/moderation/client.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/moderation/rest_client.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/rate_limit.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/stream_response.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/tasks.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/tests/test_webhook.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/utils/__init__.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/utils/event_emitter.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/version.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/video/__init__.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/video/async_call.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/video/async_client.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/video/async_rest_client.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/video/call.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/video/client.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/video/openai.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rest_client.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/README.md +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/__init__.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/connection_manager.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/connection_utils.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/coordinator/__init__.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/coordinator/backoff.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/coordinator/errors.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/coordinator/ws.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/encoders_patches.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/g711.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/location_discovery.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/models.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/network_monitor.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/participants.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/pb/__init__.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/pb/stream/__init__.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/pb/stream/video/__init__.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/pb/stream/video/sfu/__init__.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/pb/stream/video/sfu/event/__init__.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/pb/stream/video/sfu/event/events_pb2.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/pb/stream/video/sfu/event/events_pb2.pyi +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/pb/stream/video/sfu/models/__init__.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/pb/stream/video/sfu/models/models_pb2.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/pb/stream/video/sfu/models/models_pb2.pyi +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/pb/stream/video/sfu/signal_rpc/__init__.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/pb/stream/video/sfu/signal_rpc/signal_pb2.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/pb/stream/video/sfu/signal_rpc/signal_pb2.pyi +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/pb/stream/video/sfu/signal_rpc/signal_twirp.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/pc.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/peer_connection.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/reconnection.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/recording.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/signaling.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/stats_reporter.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/stats_tracer.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/tracer.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/tracks.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/twirp_client_wrapper.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/utils.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/getstream/webhook.py +0 -0
- {getstream-4.0.0 → getstream-4.2.0}/pytest.ini +0 -0
|
@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [4.2.0] - 2026-07-24
|
|
11
|
+
|
|
10
12
|
### Added
|
|
11
13
|
|
|
12
14
|
- Standardized error hierarchy ([CHA-2958](https://linear.app/stream/issue/CHA-2958)).
|
|
@@ -45,7 +47,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
45
47
|
- `request_timeout: float` (seconds): default `30.0` (was `6.0`; see Behavior changes)
|
|
46
48
|
|
|
47
49
|
These tune the underlying `httpx.Limits` and `httpx.Timeout`. The existing `http_client=` and `transport=` kwargs continue to act as escape hatches; when `http_client` is set, none of the four new kwargs apply. Env-var fallbacks for the new kwargs: `STREAM_MAX_CONNS_PER_HOST`, `STREAM_IDLE_TIMEOUT`, `STREAM_CONNECT_TIMEOUT`, `STREAM_REQUEST_TIMEOUT`.
|
|
48
|
-
|
|
50
|
+
|
|
51
|
+
- Structured logging ([CHA-2957](https://linear.app/stream/issue/CHA-2957)).
|
|
52
|
+
New `logger: logging.Logger | None` and `log_bodies: bool = False` kwargs on
|
|
53
|
+
`Stream(...)` and `AsyncStream(...)`. Off by default: nothing is emitted
|
|
54
|
+
unless a logger is passed. Four events, each carrying structured fields via
|
|
55
|
+
the stdlib logging `extra={}` mechanism:
|
|
56
|
+
- `client.initialized` (INFO, once at construction): SDK name/version,
|
|
57
|
+
resolved pool knobs, `gzip_enabled`, `user_http_client`, `log_bodies`.
|
|
58
|
+
Replaces the old plain-text pool-config INFO line.
|
|
59
|
+
- `http.request.sent` (DEBUG, before each request): method, path,
|
|
60
|
+
redacted query, `stream.endpoint_name`.
|
|
61
|
+
- `http.response.received` (DEBUG, after any response including
|
|
62
|
+
4xx/5xx): status code, response size, `duration_ms`. HTTP error
|
|
63
|
+
status codes are data, not failures.
|
|
64
|
+
- `http.request.failed` (ERROR, transport failure only, no HTTP
|
|
65
|
+
response received): `error.type` (`connection_reset` / `timeout` /
|
|
66
|
+
`dns_failure` / `tls_handshake_failed` / `unknown`), `error.message`.
|
|
67
|
+
Query values for `api_key`/`api_secret`/`token` and top-level JSON body
|
|
68
|
+
keys `api_secret`/`token`/`password` are always redacted; no opt-out.
|
|
69
|
+
Bodies are never logged by default; `log_bodies=True` adds redacted
|
|
70
|
+
request/response bodies and emits one WARNING at construction.
|
|
49
71
|
|
|
50
72
|
- Webhook handling spec helpers (CHA-2961): `UnknownEvent` dataclass for
|
|
51
73
|
forward-compat; `gunzip_payload`, `decode_sqs_payload`, `decode_sns_payload`
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: getstream
|
|
3
|
-
Version: 4.
|
|
3
|
+
Version: 4.2.0
|
|
4
4
|
Summary: GetStream Python SDK - Build scalable activity feeds, chat, and video calling applications
|
|
5
5
|
Author-email: sachaarbonel <sacha.arbonel@hotmail.fr>, tbarbugli <tbarbugli@gmail.com>
|
|
6
6
|
License-File: LICENSE.md
|
|
@@ -151,6 +151,31 @@ response: StreamResponse[StartClosedCaptionsResponse] = call.start_closed_captio
|
|
|
151
151
|
response.data # Gives the StartClosedCaptionsResponse model
|
|
152
152
|
```
|
|
153
153
|
|
|
154
|
+
### Logging
|
|
155
|
+
|
|
156
|
+
The SDK emits structured log events (`client.initialized`, `http.request.sent`, `http.response.received`, `http.request.failed`) through the stdlib `logging` module. By default nothing is printed: pass a `logging.Logger` to see them.
|
|
157
|
+
|
|
158
|
+
```python
|
|
159
|
+
import logging
|
|
160
|
+
|
|
161
|
+
logging.basicConfig(level=logging.DEBUG)
|
|
162
|
+
client = Stream(api_key="key", api_secret="secret", logger=logging.getLogger("myapp.stream"))
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Each event carries structured fields via the standard `extra={}` mechanism (for example `http.response.status_code`, `duration_ms`, `stream.endpoint_name`). Query and body values for known secret keys (`api_key`, `api_secret`, `token`, `password`) are always redacted. Request/response bodies are omitted by default; pass `log_bodies=True` to include them (still redacted, and this emits one WARNING at construction since bodies can contain other sensitive data).
|
|
166
|
+
|
|
167
|
+
### Retries
|
|
168
|
+
|
|
169
|
+
By default the client makes exactly one attempt per request and surfaces errors unchanged. Pass a `RetryConfig` to opt in to auto-retry:
|
|
170
|
+
|
|
171
|
+
```python
|
|
172
|
+
from getstream import Stream, RetryConfig
|
|
173
|
+
|
|
174
|
+
client = Stream(api_key=..., api_secret=..., retry=RetryConfig(enabled=True, max_attempts=3, max_backoff=30.0))
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Only idempotent `GET`/`HEAD` requests are retried, and only on HTTP 429 (unless the backend marked it unrecoverable) or a transport-level failure (timeout, connection reset, DNS, TLS). A 429's `Retry-After` header is honored (clamped to `max_backoff`); otherwise the delay uses full jitter over an exponential backoff. A retried failure logs `http.request.failed` at DEBUG; a final, non-retried failure logs it at ERROR (or not at all for a final 429, since that's already covered by `http.response.received`).
|
|
178
|
+
|
|
154
179
|
### App configuration
|
|
155
180
|
|
|
156
181
|
```python
|
|
@@ -99,6 +99,31 @@ response: StreamResponse[StartClosedCaptionsResponse] = call.start_closed_captio
|
|
|
99
99
|
response.data # Gives the StartClosedCaptionsResponse model
|
|
100
100
|
```
|
|
101
101
|
|
|
102
|
+
### Logging
|
|
103
|
+
|
|
104
|
+
The SDK emits structured log events (`client.initialized`, `http.request.sent`, `http.response.received`, `http.request.failed`) through the stdlib `logging` module. By default nothing is printed: pass a `logging.Logger` to see them.
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
import logging
|
|
108
|
+
|
|
109
|
+
logging.basicConfig(level=logging.DEBUG)
|
|
110
|
+
client = Stream(api_key="key", api_secret="secret", logger=logging.getLogger("myapp.stream"))
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Each event carries structured fields via the standard `extra={}` mechanism (for example `http.response.status_code`, `duration_ms`, `stream.endpoint_name`). Query and body values for known secret keys (`api_key`, `api_secret`, `token`, `password`) are always redacted. Request/response bodies are omitted by default; pass `log_bodies=True` to include them (still redacted, and this emits one WARNING at construction since bodies can contain other sensitive data).
|
|
114
|
+
|
|
115
|
+
### Retries
|
|
116
|
+
|
|
117
|
+
By default the client makes exactly one attempt per request and surfaces errors unchanged. Pass a `RetryConfig` to opt in to auto-retry:
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
from getstream import Stream, RetryConfig
|
|
121
|
+
|
|
122
|
+
client = Stream(api_key=..., api_secret=..., retry=RetryConfig(enabled=True, max_attempts=3, max_backoff=30.0))
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Only idempotent `GET`/`HEAD` requests are retried, and only on HTTP 429 (unless the backend marked it unrecoverable) or a transport-level failure (timeout, connection reset, DNS, TLS). A 429's `Retry-After` header is honored (clamped to `max_backoff`); otherwise the delay uses full jitter over an exponential backoff. A retried failure logs `http.request.failed` at DEBUG; a final, non-retried failure logs it at ERROR (or not at all for a final 429, since that's already covered by `http.response.received`).
|
|
126
|
+
|
|
102
127
|
### App configuration
|
|
103
128
|
|
|
104
129
|
```python
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from getstream.config import RetryConfig # noqa: F401
|
|
4
|
+
from getstream.exceptions import ( # noqa: F401
|
|
5
|
+
StreamApiException,
|
|
6
|
+
StreamException,
|
|
7
|
+
StreamRateLimitException,
|
|
8
|
+
StreamTaskException,
|
|
9
|
+
StreamTransportException,
|
|
10
|
+
)
|
|
11
|
+
from getstream.stream import Stream # noqa: F401
|
|
12
|
+
from getstream.stream import AsyncStream # noqa: F401
|
|
13
|
+
|
|
14
|
+
# No-op until the caller attaches a handler: the SDK never configures the
|
|
15
|
+
# logger's level or output, it only emits at each event's documented level.
|
|
16
|
+
logging.getLogger("getstream").addHandler(logging.NullHandler())
|
|
@@ -2,6 +2,7 @@ import json
|
|
|
2
2
|
import logging
|
|
3
3
|
import mimetypes
|
|
4
4
|
import os
|
|
5
|
+
import random
|
|
5
6
|
import time
|
|
6
7
|
import uuid
|
|
7
8
|
import warnings
|
|
@@ -10,13 +11,17 @@ from typing import Any, Dict, List, Optional, Tuple, Type, cast, get_origin
|
|
|
10
11
|
|
|
11
12
|
from getstream.exceptions import (
|
|
12
13
|
StreamApiException,
|
|
14
|
+
StreamRateLimitException,
|
|
15
|
+
StreamTransportException,
|
|
13
16
|
build_api_exception,
|
|
14
17
|
wrap_transport_error,
|
|
15
18
|
)
|
|
19
|
+
from getstream.logging_utils import redact_json_body, redact_query
|
|
16
20
|
from getstream.stream_response import StreamResponse
|
|
17
21
|
from getstream.generic import T
|
|
18
22
|
import httpx
|
|
19
23
|
from getstream.config import BaseConfig
|
|
24
|
+
from getstream.version import VERSION
|
|
20
25
|
from urllib.parse import quote
|
|
21
26
|
from abc import ABC
|
|
22
27
|
from getstream.common.telemetry import (
|
|
@@ -42,6 +47,34 @@ DEFAULT_CONNECT_TIMEOUT = 10.0
|
|
|
42
47
|
logger = logging.getLogger("getstream")
|
|
43
48
|
|
|
44
49
|
|
|
50
|
+
# ── Retry policy (CHA-2959) ───────────────────────────────────────────
|
|
51
|
+
def _retry_eligible(retry, exc, method: str, attempt: int) -> bool:
|
|
52
|
+
"""Whether ``exc`` from the given 0-indexed ``attempt`` should be retried
|
|
53
|
+
under ``retry`` (a ``RetryConfig`` or ``None``). Only GET/HEAD, only HTTP
|
|
54
|
+
429 (unless marked unrecoverable) or a transport error, and only while
|
|
55
|
+
attempts remain."""
|
|
56
|
+
if retry is None or not retry.enabled:
|
|
57
|
+
return False
|
|
58
|
+
if method.upper() not in ("GET", "HEAD"):
|
|
59
|
+
return False
|
|
60
|
+
if attempt + 1 >= retry.max_attempts:
|
|
61
|
+
return False
|
|
62
|
+
if isinstance(exc, StreamRateLimitException):
|
|
63
|
+
return not bool(getattr(exc, "unrecoverable", False))
|
|
64
|
+
return isinstance(exc, StreamTransportException)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _retry_delay(retry, exc, attempt: int) -> float:
|
|
68
|
+
"""Seconds to sleep before the next attempt: honors the server's
|
|
69
|
+
``Retry-After`` when present (clamped to ``max_backoff``), else full
|
|
70
|
+
jitter over an exponential ceiling (``attempt`` is 0-indexed)."""
|
|
71
|
+
retry_after = getattr(exc, "retry_after", None)
|
|
72
|
+
if retry_after is not None and retry_after.total_seconds() > 0:
|
|
73
|
+
return min(retry_after.total_seconds(), retry.max_backoff)
|
|
74
|
+
ceil = min(retry.max_backoff, float(2**attempt))
|
|
75
|
+
return random.uniform(0.0, ceil) if ceil > 0 else 0.0
|
|
76
|
+
|
|
77
|
+
|
|
45
78
|
def _resolve_pool_knobs(obj):
|
|
46
79
|
"""Pull the 3 pool knobs off ``obj`` if BaseStream has set them, else fall back to spec defaults. Top-level ``Stream``/``AsyncStream`` sets them on ``self`` before calling ``super().__init__()``, so a directly instantiated sub-client (or test fixture) still gets sane values.
|
|
47
80
|
|
|
@@ -59,22 +92,46 @@ def _resolve_pool_knobs(obj):
|
|
|
59
92
|
)
|
|
60
93
|
|
|
61
94
|
|
|
62
|
-
def
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
95
|
+
def _resolve_logger(obj) -> logging.Logger:
|
|
96
|
+
"""The caller's injected logger (``Stream``/``AsyncStream``'s ``logger=``
|
|
97
|
+
kwarg, plumbed onto ``obj.log`` the same way as the pool knobs), or the
|
|
98
|
+
shared module logger when none was passed."""
|
|
99
|
+
return getattr(obj, "log", None) or logger
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _log_client_initialized(cfg, *, user_http_client: bool) -> None:
|
|
103
|
+
"""Emit the one-shot ``client.initialized`` event, replacing the old
|
|
104
|
+
plain-text pool-config INFO line with the structured logging schema."""
|
|
105
|
+
_resolve_logger(cfg).info(
|
|
106
|
+
"client.initialized",
|
|
107
|
+
extra={
|
|
108
|
+
"stream.sdk.name": "stream-py",
|
|
109
|
+
"stream.sdk.version": VERSION,
|
|
110
|
+
"stream.client.max_conns_per_host": cfg.max_conns_per_host,
|
|
111
|
+
"stream.client.idle_timeout_seconds": cfg.idle_timeout,
|
|
112
|
+
"stream.client.connect_timeout_seconds": cfg.connect_timeout,
|
|
113
|
+
"stream.client.request_timeout_seconds": cfg.timeout,
|
|
114
|
+
"stream.client.gzip_enabled": not user_http_client,
|
|
115
|
+
"stream.client.user_http_client": user_http_client,
|
|
116
|
+
"stream.client.log_bodies": bool(getattr(cfg, "log_bodies", False)),
|
|
117
|
+
},
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _response_body_for_log(response: httpx.Response):
|
|
122
|
+
"""Redact a response body for the ``http.response.body`` log field.
|
|
123
|
+
JSON bodies get the shallow key redaction; anything else (or anything
|
|
124
|
+
that fails to parse as JSON) is passed through as text."""
|
|
125
|
+
# Media types are case-insensitive; match application/json and any
|
|
126
|
+
# structured +json type (e.g. application/problem+json) so their bodies
|
|
127
|
+
# are redacted, not logged as raw text.
|
|
128
|
+
content_type = response.headers.get("content-type", "").lower()
|
|
129
|
+
if "application/json" in content_type or "+json" in content_type:
|
|
130
|
+
try:
|
|
131
|
+
return redact_json_body(json.loads(response.text))
|
|
132
|
+
except (ValueError, TypeError):
|
|
133
|
+
return response.text
|
|
134
|
+
return response.text
|
|
78
135
|
|
|
79
136
|
|
|
80
137
|
def _read_file_bytes(file_path: str) -> bytes:
|
|
@@ -268,7 +325,7 @@ class BaseClient(TelemetryEndpointMixin, BaseConfig, ResponseParserMixin, ABC):
|
|
|
268
325
|
op = getattr(self, "_operation_name", None)
|
|
269
326
|
return op or current_operation(self._normalize_endpoint_from_path(path)) or ""
|
|
270
327
|
|
|
271
|
-
def
|
|
328
|
+
def _attempt_sync(
|
|
272
329
|
self,
|
|
273
330
|
method: str,
|
|
274
331
|
path: str,
|
|
@@ -284,6 +341,18 @@ class BaseClient(TelemetryEndpointMixin, BaseConfig, ResponseParserMixin, ABC):
|
|
|
284
341
|
url_path, url_full, endpoint, attrs = self._prepare_request(
|
|
285
342
|
method, path, query_params, kwargs
|
|
286
343
|
)
|
|
344
|
+
log = _resolve_logger(self)
|
|
345
|
+
log_bodies = bool(getattr(self, "log_bodies", False))
|
|
346
|
+
sent_extra = {
|
|
347
|
+
"http.request.method": method,
|
|
348
|
+
"url.path": path,
|
|
349
|
+
"url.query": redact_query(query_params) or "",
|
|
350
|
+
"stream.endpoint_name": endpoint,
|
|
351
|
+
}
|
|
352
|
+
if log_bodies:
|
|
353
|
+
sent_extra["http.request.body"] = redact_json_body(kwargs.get("json"))
|
|
354
|
+
log.debug("http.request.sent", extra=sent_extra)
|
|
355
|
+
|
|
287
356
|
start = time.perf_counter()
|
|
288
357
|
# Span name uses logical operation (endpoint) rather than raw HTTP
|
|
289
358
|
with span_request(
|
|
@@ -296,6 +365,9 @@ class BaseClient(TelemetryEndpointMixin, BaseConfig, ResponseParserMixin, ABC):
|
|
|
296
365
|
url_path, params=query_params, *args, **call_kwargs
|
|
297
366
|
)
|
|
298
367
|
except httpx.RequestError as err:
|
|
368
|
+
# No failed-log here: the retry loop (_request_sync) owns
|
|
369
|
+
# http.request.failed so it can log at DEBUG when retrying
|
|
370
|
+
# and ERROR only on a final failure.
|
|
299
371
|
raise wrap_transport_error(err) from err
|
|
300
372
|
duration = parse_duration_from_body(response.content)
|
|
301
373
|
if duration:
|
|
@@ -308,6 +380,17 @@ class BaseClient(TelemetryEndpointMixin, BaseConfig, ResponseParserMixin, ABC):
|
|
|
308
380
|
pass
|
|
309
381
|
|
|
310
382
|
duration_ms = (time.perf_counter() - start) * 1000.0
|
|
383
|
+
received_extra = {
|
|
384
|
+
"http.request.method": method,
|
|
385
|
+
"url.path": path,
|
|
386
|
+
"stream.endpoint_name": endpoint,
|
|
387
|
+
"http.response.status_code": response.status_code,
|
|
388
|
+
"http.response.body.size": len(response.content or b""),
|
|
389
|
+
"duration_ms": int(duration_ms),
|
|
390
|
+
}
|
|
391
|
+
if log_bodies:
|
|
392
|
+
received_extra["http.response.body"] = _response_body_for_log(response)
|
|
393
|
+
log.debug("http.response.received", extra=received_extra)
|
|
311
394
|
# Metrics should be low-cardinality: exclude url/call_cid/channel_cid
|
|
312
395
|
metric_attrs = metric_attributes(
|
|
313
396
|
api_key=self.api_key,
|
|
@@ -318,6 +401,72 @@ class BaseClient(TelemetryEndpointMixin, BaseConfig, ResponseParserMixin, ABC):
|
|
|
318
401
|
record_metrics(duration_ms, attributes=metric_attrs)
|
|
319
402
|
return self._parse_response(response, data_type or Dict[str, Any])
|
|
320
403
|
|
|
404
|
+
def _request_sync(
|
|
405
|
+
self,
|
|
406
|
+
method: str,
|
|
407
|
+
path: str,
|
|
408
|
+
*,
|
|
409
|
+
query_params=None,
|
|
410
|
+
args=(),
|
|
411
|
+
kwargs=None,
|
|
412
|
+
data_type: Optional[Type[T]] = None,
|
|
413
|
+
):
|
|
414
|
+
"""Retry loop around ``_attempt_sync``. Disabled (default) retry
|
|
415
|
+
policy means exactly one attempt, errors surface unchanged. When
|
|
416
|
+
enabled, retries GET/HEAD on HTTP 429 / transport errors per
|
|
417
|
+
``_retry_eligible``/``_retry_delay``, owning the ``http.request.failed``
|
|
418
|
+
log level so a retried failure logs at DEBUG and only a final
|
|
419
|
+
transport failure logs at ERROR (a final 429 is already covered by
|
|
420
|
+
``http.response.received``)."""
|
|
421
|
+
retry = getattr(self, "retry", None)
|
|
422
|
+
log = _resolve_logger(self)
|
|
423
|
+
endpoint = self._endpoint_name(path)
|
|
424
|
+
attempt = 0
|
|
425
|
+
while True:
|
|
426
|
+
t0 = time.perf_counter()
|
|
427
|
+
try:
|
|
428
|
+
return self._attempt_sync(
|
|
429
|
+
method,
|
|
430
|
+
path,
|
|
431
|
+
query_params=query_params,
|
|
432
|
+
args=args,
|
|
433
|
+
kwargs=kwargs,
|
|
434
|
+
data_type=data_type,
|
|
435
|
+
)
|
|
436
|
+
except (StreamRateLimitException, StreamTransportException) as exc:
|
|
437
|
+
duration_ms = int((time.perf_counter() - t0) * 1000)
|
|
438
|
+
if _retry_eligible(retry, exc, method, attempt):
|
|
439
|
+
delay = _retry_delay(retry, exc, attempt)
|
|
440
|
+
extra = {
|
|
441
|
+
"http.request.method": method,
|
|
442
|
+
"url.path": path,
|
|
443
|
+
"stream.endpoint_name": endpoint,
|
|
444
|
+
"retry.attempt": attempt + 1,
|
|
445
|
+
"backoff_seconds": round(delay, 3),
|
|
446
|
+
"error.message": str(exc.__cause__ or exc),
|
|
447
|
+
"duration_ms": duration_ms,
|
|
448
|
+
}
|
|
449
|
+
if isinstance(exc, StreamTransportException):
|
|
450
|
+
extra["error.type"] = exc.error_type
|
|
451
|
+
log.debug("http.request.failed", extra=extra)
|
|
452
|
+
time.sleep(delay)
|
|
453
|
+
attempt += 1
|
|
454
|
+
continue
|
|
455
|
+
if isinstance(exc, StreamTransportException):
|
|
456
|
+
log.error(
|
|
457
|
+
"http.request.failed",
|
|
458
|
+
extra={
|
|
459
|
+
"http.request.method": method,
|
|
460
|
+
"url.path": path,
|
|
461
|
+
"stream.endpoint_name": endpoint,
|
|
462
|
+
"retry.attempt": attempt + 1,
|
|
463
|
+
"error.type": exc.error_type,
|
|
464
|
+
"error.message": str(exc.__cause__ or exc),
|
|
465
|
+
"duration_ms": duration_ms,
|
|
466
|
+
},
|
|
467
|
+
)
|
|
468
|
+
raise
|
|
469
|
+
|
|
321
470
|
def patch(
|
|
322
471
|
self,
|
|
323
472
|
path,
|
|
@@ -576,7 +725,7 @@ class AsyncBaseClient(TelemetryEndpointMixin, BaseConfig, ResponseParserMixin, A
|
|
|
576
725
|
op = getattr(self, "_operation_name", None)
|
|
577
726
|
return op or current_operation(self._normalize_endpoint_from_path(path)) or ""
|
|
578
727
|
|
|
579
|
-
async def
|
|
728
|
+
async def _attempt_async(
|
|
580
729
|
self,
|
|
581
730
|
method: str,
|
|
582
731
|
path: str,
|
|
@@ -593,6 +742,18 @@ class AsyncBaseClient(TelemetryEndpointMixin, BaseConfig, ResponseParserMixin, A
|
|
|
593
742
|
url_path, url_full, endpoint, attrs = self._prepare_request(
|
|
594
743
|
method, path, query_params, kwargs
|
|
595
744
|
)
|
|
745
|
+
log = _resolve_logger(self)
|
|
746
|
+
log_bodies = bool(getattr(self, "log_bodies", False))
|
|
747
|
+
sent_extra = {
|
|
748
|
+
"http.request.method": method,
|
|
749
|
+
"url.path": path,
|
|
750
|
+
"url.query": redact_query(query_params) or "",
|
|
751
|
+
"stream.endpoint_name": endpoint,
|
|
752
|
+
}
|
|
753
|
+
if log_bodies:
|
|
754
|
+
sent_extra["http.request.body"] = redact_json_body(kwargs.get("json"))
|
|
755
|
+
log.debug("http.request.sent", extra=sent_extra)
|
|
756
|
+
|
|
596
757
|
start = time.perf_counter()
|
|
597
758
|
with span_request(
|
|
598
759
|
endpoint, attributes=attrs, request_body=kwargs.get("json")
|
|
@@ -612,6 +773,9 @@ class AsyncBaseClient(TelemetryEndpointMixin, BaseConfig, ResponseParserMixin, A
|
|
|
612
773
|
url_path, params=query_params, *args, **call_kwargs
|
|
613
774
|
)
|
|
614
775
|
except httpx.RequestError as err:
|
|
776
|
+
# No failed-log here: the retry loop (_request_async) owns
|
|
777
|
+
# http.request.failed so it can log at DEBUG when retrying
|
|
778
|
+
# and ERROR only on a final failure.
|
|
615
779
|
raise wrap_transport_error(err) from err
|
|
616
780
|
duration = parse_duration_from_body(response.content)
|
|
617
781
|
if duration:
|
|
@@ -624,6 +788,19 @@ class AsyncBaseClient(TelemetryEndpointMixin, BaseConfig, ResponseParserMixin, A
|
|
|
624
788
|
pass
|
|
625
789
|
|
|
626
790
|
duration_ms = (time.perf_counter() - start) * 1000.0
|
|
791
|
+
received_extra = {
|
|
792
|
+
"http.request.method": method,
|
|
793
|
+
"url.path": path,
|
|
794
|
+
"stream.endpoint_name": endpoint,
|
|
795
|
+
"http.response.status_code": response.status_code,
|
|
796
|
+
"http.response.body.size": len(response.content or b""),
|
|
797
|
+
"duration_ms": int(duration_ms),
|
|
798
|
+
}
|
|
799
|
+
if log_bodies:
|
|
800
|
+
received_extra["http.response.body"] = await asyncio.to_thread(
|
|
801
|
+
_response_body_for_log, response
|
|
802
|
+
)
|
|
803
|
+
log.debug("http.response.received", extra=received_extra)
|
|
627
804
|
# Metrics should be low-cardinality: exclude url/call_cid/channel_cid
|
|
628
805
|
metric_attrs = metric_attributes(
|
|
629
806
|
api_key=self.api_key,
|
|
@@ -636,6 +813,66 @@ class AsyncBaseClient(TelemetryEndpointMixin, BaseConfig, ResponseParserMixin, A
|
|
|
636
813
|
self._parse_response, response, data_type or Dict[str, Any]
|
|
637
814
|
)
|
|
638
815
|
|
|
816
|
+
async def _request_async(
|
|
817
|
+
self,
|
|
818
|
+
method: str,
|
|
819
|
+
path: str,
|
|
820
|
+
*,
|
|
821
|
+
query_params=None,
|
|
822
|
+
args=(),
|
|
823
|
+
kwargs=None,
|
|
824
|
+
data_type: Optional[Type[T]] = None,
|
|
825
|
+
):
|
|
826
|
+
"""Async twin of ``BaseClient._request_sync``; see that docstring."""
|
|
827
|
+
retry = getattr(self, "retry", None)
|
|
828
|
+
log = _resolve_logger(self)
|
|
829
|
+
endpoint = self._endpoint_name(path)
|
|
830
|
+
attempt = 0
|
|
831
|
+
while True:
|
|
832
|
+
t0 = time.perf_counter()
|
|
833
|
+
try:
|
|
834
|
+
return await self._attempt_async(
|
|
835
|
+
method,
|
|
836
|
+
path,
|
|
837
|
+
query_params=query_params,
|
|
838
|
+
args=args,
|
|
839
|
+
kwargs=kwargs,
|
|
840
|
+
data_type=data_type,
|
|
841
|
+
)
|
|
842
|
+
except (StreamRateLimitException, StreamTransportException) as exc:
|
|
843
|
+
duration_ms = int((time.perf_counter() - t0) * 1000)
|
|
844
|
+
if _retry_eligible(retry, exc, method, attempt):
|
|
845
|
+
delay = _retry_delay(retry, exc, attempt)
|
|
846
|
+
extra = {
|
|
847
|
+
"http.request.method": method,
|
|
848
|
+
"url.path": path,
|
|
849
|
+
"stream.endpoint_name": endpoint,
|
|
850
|
+
"retry.attempt": attempt + 1,
|
|
851
|
+
"backoff_seconds": round(delay, 3),
|
|
852
|
+
"error.message": str(exc.__cause__ or exc),
|
|
853
|
+
"duration_ms": duration_ms,
|
|
854
|
+
}
|
|
855
|
+
if isinstance(exc, StreamTransportException):
|
|
856
|
+
extra["error.type"] = exc.error_type
|
|
857
|
+
log.debug("http.request.failed", extra=extra)
|
|
858
|
+
await asyncio.sleep(delay)
|
|
859
|
+
attempt += 1
|
|
860
|
+
continue
|
|
861
|
+
if isinstance(exc, StreamTransportException):
|
|
862
|
+
log.error(
|
|
863
|
+
"http.request.failed",
|
|
864
|
+
extra={
|
|
865
|
+
"http.request.method": method,
|
|
866
|
+
"url.path": path,
|
|
867
|
+
"stream.endpoint_name": endpoint,
|
|
868
|
+
"retry.attempt": attempt + 1,
|
|
869
|
+
"error.type": exc.error_type,
|
|
870
|
+
"error.message": str(exc.__cause__ or exc),
|
|
871
|
+
"duration_ms": duration_ms,
|
|
872
|
+
},
|
|
873
|
+
)
|
|
874
|
+
raise
|
|
875
|
+
|
|
639
876
|
async def patch(
|
|
640
877
|
self,
|
|
641
878
|
path,
|
|
@@ -1,6 +1,26 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
1
3
|
from getstream.version import VERSION
|
|
2
4
|
|
|
3
5
|
|
|
6
|
+
@dataclass(frozen=True)
|
|
7
|
+
class RetryConfig:
|
|
8
|
+
"""Opt-in auto-retry policy. Disabled by default: the client performs
|
|
9
|
+
exactly one attempt and surfaces errors unchanged. When enabled, only
|
|
10
|
+
GET/HEAD requests failing with HTTP 429 or a transport error are retried,
|
|
11
|
+
and never when the backend marked the error unrecoverable."""
|
|
12
|
+
|
|
13
|
+
enabled: bool = False
|
|
14
|
+
max_attempts: int = 3
|
|
15
|
+
max_backoff: float = 30.0
|
|
16
|
+
|
|
17
|
+
def __post_init__(self):
|
|
18
|
+
if self.max_attempts < 1:
|
|
19
|
+
raise ValueError("max_attempts must be >= 1")
|
|
20
|
+
if self.max_backoff < 0:
|
|
21
|
+
raise ValueError("max_backoff must be >= 0")
|
|
22
|
+
|
|
23
|
+
|
|
4
24
|
class BaseConfig:
|
|
5
25
|
def __init__(
|
|
6
26
|
self,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Redaction helpers for the SDK's structured log events. Secret values are
|
|
2
|
+
replaced with a literal marker; the scan is shallow by design."""
|
|
3
|
+
|
|
4
|
+
REDACTED = "<redacted>"
|
|
5
|
+
REDACTED_QUERY_PARAMS = {"api_key", "api_secret", "token"}
|
|
6
|
+
REDACTED_BODY_KEYS = {"api_secret", "token", "password"}
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def redact_query(params):
|
|
10
|
+
if not params:
|
|
11
|
+
return params
|
|
12
|
+
return {
|
|
13
|
+
k: (REDACTED if k.lower() in REDACTED_QUERY_PARAMS else v)
|
|
14
|
+
for k, v in dict(params).items()
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def redact_json_body(body):
|
|
19
|
+
if not isinstance(body, dict):
|
|
20
|
+
return body
|
|
21
|
+
return {k: (REDACTED if k in REDACTED_BODY_KEYS else v) for k, v in body.items()}
|
|
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
from contextlib import AsyncExitStack
|
|
4
4
|
from functools import cached_property
|
|
5
|
+
import logging
|
|
5
6
|
import time
|
|
6
7
|
from typing import List, Optional
|
|
7
8
|
from uuid import uuid4
|
|
@@ -10,8 +11,9 @@ import httpx
|
|
|
10
11
|
import jwt
|
|
11
12
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
12
13
|
|
|
13
|
-
from getstream.base import
|
|
14
|
+
from getstream.base import _log_client_initialized, _resolve_logger
|
|
14
15
|
from getstream.common import telemetry
|
|
16
|
+
from getstream.config import RetryConfig
|
|
15
17
|
from getstream.chat.client import ChatClient
|
|
16
18
|
from getstream.chat.async_client import ChatClient as AsyncChatClient
|
|
17
19
|
from getstream.common.async_client import CommonClient as AsyncCommonClient
|
|
@@ -92,6 +94,9 @@ class BaseStream:
|
|
|
92
94
|
max_conns_per_host: Optional[int] = None,
|
|
93
95
|
idle_timeout: Optional[float] = None,
|
|
94
96
|
connect_timeout: Optional[float] = None,
|
|
97
|
+
logger: Optional[logging.Logger] = None,
|
|
98
|
+
log_bodies: bool = False,
|
|
99
|
+
retry: Optional[RetryConfig] = None,
|
|
95
100
|
):
|
|
96
101
|
"""Build a Stream client.
|
|
97
102
|
|
|
@@ -114,6 +119,9 @@ class BaseStream:
|
|
|
114
119
|
max_conns_per_host: Max concurrent TCP connections per host. Default 5. Ignored when ``http_client`` is set.
|
|
115
120
|
idle_timeout: Idle connection lifetime in seconds. Default 55.0 (sits 5s under the typical 60s LB idle timeout). Ignored when ``http_client`` is set.
|
|
116
121
|
connect_timeout: TCP + TLS handshake timeout in seconds. Default 10.0. Ignored when ``http_client`` is set.
|
|
122
|
+
logger: Optional stdlib ``logging.Logger`` for the SDK's structured log events (``client.initialized``, ``http.request.sent``, ``http.response.received``, ``http.request.failed``). Defaults to ``logging.getLogger("getstream")``, which is a no-op until the caller attaches a handler.
|
|
123
|
+
log_bodies: When ``True``, adds redacted request/response bodies to the request/response log events. Off by default. Emits one WARNING at construction when enabled.
|
|
124
|
+
retry: Optional ``RetryConfig`` enabling auto-retry of GET/HEAD requests on HTTP 429 or transport errors. Disabled by default (a single attempt; errors surface unchanged).
|
|
117
125
|
|
|
118
126
|
Raises:
|
|
119
127
|
ValueError: If both ``transport`` and ``http_client`` are set; if neither ``api_secret`` nor ``token`` can be resolved; if both are provided; if either is the empty string; if ``api_key`` is missing; or if ``request_timeout`` is not a positive number.
|
|
@@ -189,6 +197,18 @@ class BaseStream:
|
|
|
189
197
|
self._transport = transport
|
|
190
198
|
self._http_client = http_client
|
|
191
199
|
self.token = token or self._create_token()
|
|
200
|
+
# log / log_bodies are read by BaseClient via getattr(self, ...), same
|
|
201
|
+
# plumbing as the pool knobs below: the intermediate generated REST
|
|
202
|
+
# clients (CommonRestClient etc.) do not forward these kwargs, so they
|
|
203
|
+
# must be set on self before super().__init__() and copied onto
|
|
204
|
+
# sub-clients in _apply_shared_client.
|
|
205
|
+
self.log = logger
|
|
206
|
+
self.log_bodies = log_bodies
|
|
207
|
+
# retry: same getattr(self, ...) plumbing as the pool knobs and log/
|
|
208
|
+
# log_bodies above, since the intermediate generated REST clients do
|
|
209
|
+
# not forward this kwarg either. Read by BaseClient/AsyncBaseClient's
|
|
210
|
+
# request loop and copied onto sub-clients in _apply_shared_client.
|
|
211
|
+
self.retry = retry
|
|
192
212
|
# Pool knobs are read by BaseClient via getattr(self, ...) since the intermediate generated REST clients (CommonRestClient etc.) do not forward these kwargs. self.max_conns_per_host / idle_timeout / connect_timeout were set above before super().__init__().
|
|
193
213
|
super().__init__(
|
|
194
214
|
self.api_key, self.base_url, self.token, self.timeout, self.user_agent
|
|
@@ -203,9 +223,17 @@ class BaseStream:
|
|
|
203
223
|
# the parent's client avoids that and keeps one pool per Stream.
|
|
204
224
|
self._shared_client = self.client
|
|
205
225
|
|
|
206
|
-
# Emit the
|
|
207
|
-
# resolved knobs on the top-level client. Sub-clients no longer log
|
|
208
|
-
|
|
226
|
+
# Emit the client.initialized event exactly once per Stream, reflecting
|
|
227
|
+
# the resolved knobs on the top-level client. Sub-clients no longer log
|
|
228
|
+
# their own construction.
|
|
229
|
+
_log_client_initialized(self, user_http_client=http_client is not None)
|
|
230
|
+
if self.log_bodies:
|
|
231
|
+
_resolve_logger(self).warning(
|
|
232
|
+
"HTTP request/response bodies will be logged. Auth headers "
|
|
233
|
+
"and known-secret fields are still redacted, but other "
|
|
234
|
+
"sensitive data (messages, PII) may appear in logs. Disable "
|
|
235
|
+
"for production."
|
|
236
|
+
)
|
|
209
237
|
|
|
210
238
|
@property
|
|
211
239
|
def api_secret(self) -> str:
|
|
@@ -234,6 +262,12 @@ class BaseStream:
|
|
|
234
262
|
sub_client.client.close()
|
|
235
263
|
sub_client.client = self._shared_client
|
|
236
264
|
sub_client._owns_http_client = False
|
|
265
|
+
# log / log_bodies: same getattr(self, ..., default) plumbing as the
|
|
266
|
+
# pool knobs, so sub-clients (which issue the actual requests) emit
|
|
267
|
+
# through the caller's logger instead of silently falling back.
|
|
268
|
+
sub_client.log = getattr(self, "log", None)
|
|
269
|
+
sub_client.log_bodies = getattr(self, "log_bodies", False)
|
|
270
|
+
sub_client.retry = getattr(self, "retry", None)
|
|
237
271
|
return sub_client
|
|
238
272
|
|
|
239
273
|
def create_token(
|
|
@@ -284,6 +318,8 @@ class BaseStream:
|
|
|
284
318
|
idle_timeout=self.idle_timeout,
|
|
285
319
|
connect_timeout=self.connect_timeout,
|
|
286
320
|
user_agent=self.user_agent,
|
|
321
|
+
logger=self.log,
|
|
322
|
+
log_bodies=self.log_bodies,
|
|
287
323
|
)
|
|
288
324
|
|
|
289
325
|
def create_call_token(
|
|
@@ -543,6 +579,8 @@ class Stream(BaseStream, CommonClient):
|
|
|
543
579
|
connect_timeout=self.connect_timeout,
|
|
544
580
|
base_url=self.base_url,
|
|
545
581
|
user_agent=self.user_agent,
|
|
582
|
+
logger=self.log,
|
|
583
|
+
log_bodies=self.log_bodies,
|
|
546
584
|
)
|
|
547
585
|
|
|
548
586
|
@cached_property
|