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.
Files changed (122) hide show
  1. {getstream-4.0.0 → getstream-4.2.0}/CHANGELOG.md +23 -1
  2. {getstream-4.0.0 → getstream-4.2.0}/PKG-INFO +26 -1
  3. {getstream-4.0.0 → getstream-4.2.0}/README.md +25 -0
  4. getstream-4.2.0/getstream/__init__.py +16 -0
  5. {getstream-4.0.0 → getstream-4.2.0}/getstream/base.py +255 -18
  6. {getstream-4.0.0 → getstream-4.2.0}/getstream/config.py +20 -0
  7. getstream-4.2.0/getstream/logging_utils.py +21 -0
  8. {getstream-4.0.0 → getstream-4.2.0}/getstream/stream.py +42 -4
  9. getstream-4.2.0/getstream/video/rtc/audio_track.py +181 -0
  10. {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/track_util.py +100 -1
  11. {getstream-4.0.0 → getstream-4.2.0}/pyproject.toml +1 -1
  12. {getstream-4.0.0 → getstream-4.2.0}/uv.lock +0 -4
  13. getstream-4.0.0/getstream/__init__.py +0 -9
  14. getstream-4.0.0/getstream/utils/retry.py +0 -71
  15. getstream-4.0.0/getstream/video/rtc/audio_track.py +0 -257
  16. {getstream-4.0.0 → getstream-4.2.0}/.cursor/worktrees.json +0 -0
  17. {getstream-4.0.0 → getstream-4.2.0}/.env.example +0 -0
  18. {getstream-4.0.0 → getstream-4.2.0}/.github/actions/python-uv-setup/action.yml +0 -0
  19. {getstream-4.0.0 → getstream-4.2.0}/.github/workflows/ci.yml +0 -0
  20. {getstream-4.0.0 → getstream-4.2.0}/.github/workflows/release.yml +0 -0
  21. {getstream-4.0.0 → getstream-4.2.0}/.github/workflows/run_tests.yml +0 -0
  22. {getstream-4.0.0 → getstream-4.2.0}/.github/workflows/stream-py.code-workspace +0 -0
  23. {getstream-4.0.0 → getstream-4.2.0}/.gitignore +0 -0
  24. {getstream-4.0.0 → getstream-4.2.0}/.gitmodules +0 -0
  25. {getstream-4.0.0 → getstream-4.2.0}/.pre-commit-config.yaml +0 -0
  26. {getstream-4.0.0 → getstream-4.2.0}/AGENTS.md +0 -0
  27. {getstream-4.0.0 → getstream-4.2.0}/DEVELOPMENT.md +0 -0
  28. {getstream-4.0.0 → getstream-4.2.0}/LICENSE.md +0 -0
  29. {getstream-4.0.0 → getstream-4.2.0}/MIGRATION_v2_to_v3.md +0 -0
  30. {getstream-4.0.0 → getstream-4.2.0}/Makefile +0 -0
  31. {getstream-4.0.0 → getstream-4.2.0}/dev.py +0 -0
  32. {getstream-4.0.0 → getstream-4.2.0}/docs/migration-from-stream-chat-python/01-setup-and-auth.md +0 -0
  33. {getstream-4.0.0 → getstream-4.2.0}/docs/migration-from-stream-chat-python/02-users.md +0 -0
  34. {getstream-4.0.0 → getstream-4.2.0}/docs/migration-from-stream-chat-python/03-channels.md +0 -0
  35. {getstream-4.0.0 → getstream-4.2.0}/docs/migration-from-stream-chat-python/04-messages-and-reactions.md +0 -0
  36. {getstream-4.0.0 → getstream-4.2.0}/docs/migration-from-stream-chat-python/05-moderation.md +0 -0
  37. {getstream-4.0.0 → getstream-4.2.0}/docs/migration-from-stream-chat-python/06-devices.md +0 -0
  38. {getstream-4.0.0 → getstream-4.2.0}/docs/migration-from-stream-chat-python/README.md +0 -0
  39. {getstream-4.0.0 → getstream-4.2.0}/generate.sh +0 -0
  40. {getstream-4.0.0 → getstream-4.2.0}/generate_webrtc.sh +0 -0
  41. {getstream-4.0.0 → getstream-4.2.0}/getstream/chat/__init__.py +0 -0
  42. {getstream-4.0.0 → getstream-4.2.0}/getstream/chat/async_channel.py +0 -0
  43. {getstream-4.0.0 → getstream-4.2.0}/getstream/chat/async_client.py +0 -0
  44. {getstream-4.0.0 → getstream-4.2.0}/getstream/chat/async_rest_client.py +0 -0
  45. {getstream-4.0.0 → getstream-4.2.0}/getstream/chat/channel.py +0 -0
  46. {getstream-4.0.0 → getstream-4.2.0}/getstream/chat/client.py +0 -0
  47. {getstream-4.0.0 → getstream-4.2.0}/getstream/chat/rest_client.py +0 -0
  48. {getstream-4.0.0 → getstream-4.2.0}/getstream/common/__init__.py +0 -0
  49. {getstream-4.0.0 → getstream-4.2.0}/getstream/common/async_client.py +0 -0
  50. {getstream-4.0.0 → getstream-4.2.0}/getstream/common/async_rest_client.py +0 -0
  51. {getstream-4.0.0 → getstream-4.2.0}/getstream/common/client.py +0 -0
  52. {getstream-4.0.0 → getstream-4.2.0}/getstream/common/rest_client.py +0 -0
  53. {getstream-4.0.0 → getstream-4.2.0}/getstream/common/telemetry.py +0 -0
  54. {getstream-4.0.0 → getstream-4.2.0}/getstream/exceptions.py +0 -0
  55. {getstream-4.0.0 → getstream-4.2.0}/getstream/feeds/__init__.py +0 -0
  56. {getstream-4.0.0 → getstream-4.2.0}/getstream/feeds/client.py +0 -0
  57. {getstream-4.0.0 → getstream-4.2.0}/getstream/feeds/feeds.py +0 -0
  58. {getstream-4.0.0 → getstream-4.2.0}/getstream/feeds/rest_client.py +0 -0
  59. {getstream-4.0.0 → getstream-4.2.0}/getstream/generic.py +0 -0
  60. {getstream-4.0.0 → getstream-4.2.0}/getstream/meta.py +0 -0
  61. {getstream-4.0.0 → getstream-4.2.0}/getstream/models/__init__.py +0 -0
  62. {getstream-4.0.0 → getstream-4.2.0}/getstream/moderation/__init__.py +0 -0
  63. {getstream-4.0.0 → getstream-4.2.0}/getstream/moderation/async_client.py +0 -0
  64. {getstream-4.0.0 → getstream-4.2.0}/getstream/moderation/async_rest_client.py +0 -0
  65. {getstream-4.0.0 → getstream-4.2.0}/getstream/moderation/client.py +0 -0
  66. {getstream-4.0.0 → getstream-4.2.0}/getstream/moderation/rest_client.py +0 -0
  67. {getstream-4.0.0 → getstream-4.2.0}/getstream/rate_limit.py +0 -0
  68. {getstream-4.0.0 → getstream-4.2.0}/getstream/stream_response.py +0 -0
  69. {getstream-4.0.0 → getstream-4.2.0}/getstream/tasks.py +0 -0
  70. {getstream-4.0.0 → getstream-4.2.0}/getstream/tests/test_webhook.py +0 -0
  71. {getstream-4.0.0 → getstream-4.2.0}/getstream/utils/__init__.py +0 -0
  72. {getstream-4.0.0 → getstream-4.2.0}/getstream/utils/event_emitter.py +0 -0
  73. {getstream-4.0.0 → getstream-4.2.0}/getstream/version.py +0 -0
  74. {getstream-4.0.0 → getstream-4.2.0}/getstream/video/__init__.py +0 -0
  75. {getstream-4.0.0 → getstream-4.2.0}/getstream/video/async_call.py +0 -0
  76. {getstream-4.0.0 → getstream-4.2.0}/getstream/video/async_client.py +0 -0
  77. {getstream-4.0.0 → getstream-4.2.0}/getstream/video/async_rest_client.py +0 -0
  78. {getstream-4.0.0 → getstream-4.2.0}/getstream/video/call.py +0 -0
  79. {getstream-4.0.0 → getstream-4.2.0}/getstream/video/client.py +0 -0
  80. {getstream-4.0.0 → getstream-4.2.0}/getstream/video/openai.py +0 -0
  81. {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rest_client.py +0 -0
  82. {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/README.md +0 -0
  83. {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/__init__.py +0 -0
  84. {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/connection_manager.py +0 -0
  85. {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/connection_utils.py +0 -0
  86. {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/coordinator/__init__.py +0 -0
  87. {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/coordinator/backoff.py +0 -0
  88. {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/coordinator/errors.py +0 -0
  89. {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/coordinator/ws.py +0 -0
  90. {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/encoders_patches.py +0 -0
  91. {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/g711.py +0 -0
  92. {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/location_discovery.py +0 -0
  93. {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/models.py +0 -0
  94. {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/network_monitor.py +0 -0
  95. {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/participants.py +0 -0
  96. {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/pb/__init__.py +0 -0
  97. {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/pb/stream/__init__.py +0 -0
  98. {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/pb/stream/video/__init__.py +0 -0
  99. {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/pb/stream/video/sfu/__init__.py +0 -0
  100. {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/pb/stream/video/sfu/event/__init__.py +0 -0
  101. {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/pb/stream/video/sfu/event/events_pb2.py +0 -0
  102. {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/pb/stream/video/sfu/event/events_pb2.pyi +0 -0
  103. {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/pb/stream/video/sfu/models/__init__.py +0 -0
  104. {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/pb/stream/video/sfu/models/models_pb2.py +0 -0
  105. {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/pb/stream/video/sfu/models/models_pb2.pyi +0 -0
  106. {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/pb/stream/video/sfu/signal_rpc/__init__.py +0 -0
  107. {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/pb/stream/video/sfu/signal_rpc/signal_pb2.py +0 -0
  108. {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/pb/stream/video/sfu/signal_rpc/signal_pb2.pyi +0 -0
  109. {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/pb/stream/video/sfu/signal_rpc/signal_twirp.py +0 -0
  110. {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/pc.py +0 -0
  111. {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/peer_connection.py +0 -0
  112. {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/reconnection.py +0 -0
  113. {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/recording.py +0 -0
  114. {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/signaling.py +0 -0
  115. {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/stats_reporter.py +0 -0
  116. {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/stats_tracer.py +0 -0
  117. {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/tracer.py +0 -0
  118. {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/tracks.py +0 -0
  119. {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/twirp_client_wrapper.py +0 -0
  120. {getstream-4.0.0 → getstream-4.2.0}/getstream/video/rtc/utils.py +0 -0
  121. {getstream-4.0.0 → getstream-4.2.0}/getstream/webhook.py +0 -0
  122. {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
- - INFO log on client construction (logger `getstream`) lists the effective pool config and whether a user-supplied `http_client` is in use.
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.0.0
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 _log_pool_config(cfg, *, user_http_client: bool) -> None:
63
- if user_http_client:
64
- logger.info(
65
- "getstream connection pool: user_http_client=True (5 knobs not applied)"
66
- )
67
- else:
68
- logger.info(
69
- "getstream connection pool: "
70
- "max_conns_per_host=%s idle_timeout=%ss "
71
- "connect_timeout=%ss request_timeout=%ss "
72
- "user_http_client=False",
73
- cfg.max_conns_per_host,
74
- cfg.idle_timeout,
75
- cfg.connect_timeout,
76
- cfg.timeout,
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 _request_sync(
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 _request_async(
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 _log_pool_config
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 pool-config INFO line exactly once per Stream, reflecting the
207
- # resolved knobs on the top-level client. Sub-clients no longer log.
208
- _log_pool_config(self, user_http_client=http_client is not None)
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