+34
CHANGELOG.md
+34
CHANGELOG.md
···
1
+
# Changelog
2
+
3
+
All notable changes to this project will be documented in this file.
4
+
5
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+
## [1.1.0] - 2025-01-29
9
+
10
+
### Added
11
+
- Automatic retry logic with exponential backoff for all connections
12
+
- Three new configuration fields in `JetstreamConfig`:
13
+
- `max_backoff_seconds: Int` - Maximum wait time between retries (default: 60)
14
+
- `log_connection_events: Bool` - Log connection state changes (default: True)
15
+
- `log_retry_attempts: Bool` - Log detailed retry information (default: True)
16
+
17
+
### Changed
18
+
- `start_consumer()` now automatically retries failed connections and handles disconnections
19
+
- Enhanced error handling distinguishes between harmless timeouts and real connection failures
20
+
21
+
### Fixed
22
+
- Connection failures no longer cause application to stop
23
+
- Harmless 60-second timeouts no longer trigger unnecessary reconnections
24
+
- WebSocket disconnections are handled gracefully with automatic reconnection
25
+
26
+
## [1.0.0] - 2024-10-28
27
+
28
+
### Added
29
+
- Initial release
30
+
- WebSocket consumer for AT Protocol Jetstream events
31
+
- Support for collection and DID filtering
32
+
- Zstd compression support
33
+
- Cursor-based replay
34
+
- Event parsing for Commit, Identity, and Account events
+67
-15
README.md
+67
-15
README.md
···
9
9
gleam add goose@1
10
10
```
11
11
12
-
## Example
12
+
## Quick Start
13
13
14
14
```gleam
15
15
import goose
16
16
import gleam/io
17
-
import gleam/option
18
17
19
18
pub fn main() {
20
-
// Create a default configuration
19
+
// Use default config with automatic retry logic
21
20
let config = goose.default_config()
22
21
23
-
// Or configure with custom options
24
-
let config = goose.JetstreamConfig(
25
-
endpoint: "wss://jetstream2.us-east.bsky.network/subscribe",
26
-
wanted_collections: ["app.bsky.feed.post", "app.bsky.feed.like"],
27
-
wanted_dids: [],
28
-
cursor: option.None,
29
-
max_message_size_bytes: option.None,
30
-
compress: False,
31
-
require_hello: False,
32
-
)
33
-
34
-
// Start consuming events
35
22
goose.start_consumer(config, fn(json_event) {
36
23
let event = goose.parse_event(json_event)
37
24
···
53
40
}
54
41
```
55
42
43
+
### Custom Configuration
44
+
45
+
```gleam
46
+
import goose
47
+
import gleam/option
48
+
49
+
pub fn main() {
50
+
let config = goose.JetstreamConfig(
51
+
endpoint: "wss://jetstream2.us-east.bsky.network/subscribe",
52
+
wanted_collections: ["app.bsky.feed.post", "app.bsky.feed.like"],
53
+
wanted_dids: [],
54
+
cursor: option.None,
55
+
max_message_size_bytes: option.None,
56
+
compress: False,
57
+
require_hello: False,
58
+
// Retry configuration
59
+
max_backoff_seconds: 60, // Max wait between retries
60
+
log_connection_events: True, // Log connects/disconnects
61
+
log_retry_attempts: False, // Skip verbose retry logs
62
+
)
63
+
64
+
goose.start_consumer(config, handle_event)
65
+
}
66
+
```
67
+
56
68
## Configuration Options
69
+
70
+
**Note:** Goose automatically handles connection failures with exponential backoff retry logic (1s, 2s, 4s, 8s, 16s, 32s, up to max). All connections automatically retry on failure, reconnect on disconnection, and distinguish between harmless timeouts and real errors.
57
71
58
72
### `wanted_collections`
59
73
An array of Collection NSIDs to filter which records you receive (default: empty = all collections)
···
131
145
require_hello: True
132
146
```
133
147
148
+
### `max_backoff_seconds`
149
+
Maximum wait time in seconds between retry attempts
150
+
151
+
- Uses exponential backoff: 1s, 2s, 4s, 8s, 16s, 32s, then capped at this value
152
+
- Default: `60`
153
+
154
+
**Example:**
155
+
```gleam
156
+
max_backoff_seconds: 120 // Allow up to 2 minute waits between retries
157
+
```
158
+
159
+
### `log_connection_events`
160
+
Whether to log connection state changes (connected, disconnected)
161
+
162
+
- Set to `True` to log important connection events
163
+
- Default: `True`
164
+
- Recommended: `True` for production (know when disconnects happen)
165
+
166
+
**Example:**
167
+
```gleam
168
+
log_connection_events: True
169
+
```
170
+
171
+
### `log_retry_attempts`
172
+
Whether to log detailed retry attempt information
173
+
174
+
- Set to `True` to log attempt numbers, errors, and backoff times
175
+
- Default: `True`
176
+
- Recommended: `False` for production (reduces log noise)
177
+
178
+
**Example:**
179
+
```gleam
180
+
log_retry_attempts: False // Production: skip verbose retry logs
181
+
```
182
+
134
183
## Full Configuration Example
135
184
136
185
```gleam
···
145
194
max_message_size_bytes: option.Some(2097152), // 2MB
146
195
compress: True,
147
196
require_hello: False,
197
+
max_backoff_seconds: 60,
198
+
log_connection_events: True,
199
+
log_retry_attempts: False,
148
200
)
149
201
150
202
goose.start_consumer(config, handle_event)
+4
-1
example/src/example.gleam
+4
-1
example/src/example.gleam
···
13
13
max_message_size_bytes: option.None,
14
14
compress: True,
15
15
require_hello: False,
16
+
max_backoff_seconds: 60,
17
+
log_connection_events: True,
18
+
log_retry_attempts: True,
16
19
)
17
20
18
21
io.println("Starting Jetstream consumer...")
19
22
io.println("Connected to: " <> config.endpoint)
20
23
io.println("Listening for all events...\n")
21
24
22
-
// Start consuming and log all events
25
+
// Start consuming and log all events (automatically retries on failure)
23
26
goose.start_consumer(config, fn(json_event) {
24
27
let event = goose.parse_event(json_event)
25
28
+1
-1
gleam.toml
+1
-1
gleam.toml
+131
-12
src/goose.gleam
+131
-12
src/goose.gleam
···
1
1
import gleam/dynamic.{type Dynamic}
2
2
import gleam/dynamic/decode
3
+
import gleam/erlang/atom
3
4
import gleam/erlang/process.{type Pid}
5
+
import gleam/int
4
6
import gleam/io
5
7
import gleam/json
6
8
import gleam/list
···
44
46
max_message_size_bytes: Option(Int),
45
47
compress: Bool,
46
48
require_hello: Bool,
49
+
/// Maximum backoff time in seconds for retry logic (default: 60)
50
+
max_backoff_seconds: Int,
51
+
/// Whether to log connection events (connected, disconnected) (default: True)
52
+
log_connection_events: Bool,
53
+
/// Whether to log retry attempts and errors (default: True)
54
+
log_retry_attempts: Bool,
47
55
)
48
56
}
49
57
50
58
/// Create a default configuration for US East endpoint
59
+
/// Includes automatic retry with exponential backoff (1s, 2s, 4s, 8s, 16s, 32s, capped at 60s)
51
60
pub fn default_config() -> JetstreamConfig {
52
61
JetstreamConfig(
53
62
endpoint: "wss://jetstream2.us-east.bsky.network/subscribe",
···
57
66
max_message_size_bytes: option.None,
58
67
compress: False,
59
68
require_hello: False,
69
+
max_backoff_seconds: 60,
70
+
log_connection_events: True,
71
+
log_retry_attempts: True,
60
72
)
61
73
}
62
74
···
127
139
compress: Bool,
128
140
) -> Result(Pid, Dynamic)
129
141
130
-
/// Start consuming the Jetstream feed
142
+
/// Start consuming the Jetstream feed with automatic retry logic
143
+
///
144
+
/// Handles connection failures gracefully with exponential backoff and automatic reconnection.
145
+
/// The retry behavior is configured through the JetstreamConfig fields:
146
+
/// - max_backoff_seconds: Maximum wait time between retries
147
+
/// - log_connection_events: Log connects/disconnects
148
+
/// - log_retry_attempts: Log retry attempts and errors
149
+
///
150
+
/// Example:
151
+
/// ```gleam
152
+
/// let config = goose.default_config()
153
+
///
154
+
/// goose.start_consumer(config, fn(event_json) {
155
+
/// // Handle event
156
+
/// io.println(event_json)
157
+
/// })
158
+
/// ```
131
159
pub fn start_consumer(
132
160
config: JetstreamConfig,
133
161
on_event: fn(String) -> Nil,
134
162
) -> Nil {
163
+
start_with_retry_internal(config, on_event, 0)
164
+
}
165
+
166
+
/// Internal function to handle connection with retry
167
+
fn start_with_retry_internal(
168
+
config: JetstreamConfig,
169
+
on_event: fn(String) -> Nil,
170
+
retry_count: Int,
171
+
) -> Nil {
135
172
let url = build_url(config)
136
173
let self = process.self()
137
174
let result = connect(url, self, config.compress)
138
175
139
176
case result {
140
177
Ok(_conn_pid) -> {
141
-
receive_loop(on_event)
178
+
case config.log_connection_events {
179
+
True -> io.println("Connected to Jetstream successfully")
180
+
False -> Nil
181
+
}
182
+
// Start receiving with retry support
183
+
receive_with_retry(config, on_event)
142
184
}
143
185
Error(err) -> {
144
-
io.println("Failed to connect to Jetstream")
145
-
io.println_error(string.inspect(err))
186
+
// Connection failed, calculate backoff and retry
187
+
let backoff_seconds = calculate_backoff(retry_count, config)
188
+
case config.log_retry_attempts {
189
+
True -> {
190
+
io.println(
191
+
"Failed to connect to Jetstream (attempt "
192
+
<> int.to_string(retry_count + 1)
193
+
<> "): "
194
+
<> string.inspect(err),
195
+
)
196
+
io.println(
197
+
"Retrying in " <> int.to_string(backoff_seconds) <> " seconds...",
198
+
)
199
+
}
200
+
False -> Nil
201
+
}
202
+
203
+
// Sleep for backoff period
204
+
process.sleep(backoff_seconds * 1000)
205
+
206
+
// Retry connection
207
+
start_with_retry_internal(config, on_event, retry_count + 1)
146
208
}
147
209
}
148
210
}
149
211
150
-
/// Receive loop for WebSocket messages
151
-
fn receive_loop(on_event: fn(String) -> Nil) -> Nil {
152
-
// Call Erlang to receive one message
212
+
/// Calculate exponential backoff with configurable maximum
213
+
fn calculate_backoff(retry_count: Int, config: JetstreamConfig) -> Int {
214
+
let backoff = case retry_count {
215
+
0 -> 1
216
+
1 -> 2
217
+
2 -> 4
218
+
3 -> 8
219
+
4 -> 16
220
+
5 -> 32
221
+
_ -> config.max_backoff_seconds
222
+
}
223
+
224
+
// Cap at max_backoff_seconds
225
+
case backoff > config.max_backoff_seconds {
226
+
True -> config.max_backoff_seconds
227
+
False -> backoff
228
+
}
229
+
}
230
+
231
+
/// Receive messages with retry logic
232
+
fn receive_with_retry(
233
+
config: JetstreamConfig,
234
+
on_event: fn(String) -> Nil,
235
+
) -> Nil {
153
236
case receive_ws_message() {
154
237
Ok(text) -> {
155
238
on_event(text)
156
-
receive_loop(on_event)
239
+
receive_with_retry(config, on_event)
157
240
}
158
-
Error(_) -> {
159
-
// Timeout or error, continue loop
160
-
receive_loop(on_event)
241
+
Error(error_dynamic) -> {
242
+
// Decode error type
243
+
let atm = atom.cast_from_dynamic(error_dynamic)
244
+
let error_type = atom.to_string(atm)
245
+
246
+
case error_type {
247
+
"timeout" -> {
248
+
// No messages in 60s, connection is alive - continue
249
+
receive_with_retry(config, on_event)
250
+
}
251
+
"closed" -> {
252
+
// Connection closed - log if configured
253
+
case config.log_connection_events {
254
+
True -> io.println("Jetstream connection closed, reconnecting...")
255
+
False -> Nil
256
+
}
257
+
start_with_retry_internal(config, on_event, 0)
258
+
}
259
+
"connection_error" -> {
260
+
// Connection error - log if configured
261
+
case config.log_connection_events {
262
+
True -> io.println("Jetstream connection error, reconnecting...")
263
+
False -> Nil
264
+
}
265
+
start_with_retry_internal(config, on_event, 0)
266
+
}
267
+
_ -> {
268
+
// Unknown error - log if retry logging is enabled
269
+
case config.log_retry_attempts {
270
+
True -> {
271
+
io.println("Unknown Jetstream error: " <> error_type)
272
+
io.println("Reconnecting...")
273
+
}
274
+
False -> Nil
275
+
}
276
+
start_with_retry_internal(config, on_event, 0)
277
+
}
278
+
}
161
279
}
162
280
}
163
281
}
164
282
165
283
/// Receive a WebSocket message from the message queue
284
+
/// Returns Ok(text) for messages, or Error with one of: timeout, closed, connection_error
166
285
@external(erlang, "goose_ffi", "receive_ws_message")
167
-
fn receive_ws_message() -> Result(String, Nil)
286
+
fn receive_ws_message() -> Result(String, Dynamic)
168
287
169
288
/// Parse a JSON event string into a JetstreamEvent
170
289
pub fn parse_event(json_string: String) -> JetstreamEvent {
+4
-4
src/goose_ffi.erl
+4
-4
src/goose_ffi.erl
···
11
11
%% Ignore binary messages, try again
12
12
receive_ws_message();
13
13
{ws_closed, _Reason} ->
14
-
{error, nil};
14
+
{error, closed};
15
15
{ws_error, _Reason} ->
16
-
{error, nil};
16
+
{error, connection_error};
17
17
_Other ->
18
18
%% Ignore unexpected messages
19
19
receive_ws_message()
20
20
after 60000 ->
21
-
%% Timeout - return error to continue loop
22
-
{error, nil}
21
+
%% Timeout - connection is still alive, just no messages
22
+
{error, timeout}
23
23
end.
+24
test/goose_test.gleam
+24
test/goose_test.gleam
···
26
26
max_message_size_bytes: option.None,
27
27
compress: False,
28
28
require_hello: False,
29
+
max_backoff_seconds: 60,
30
+
log_connection_events: True,
31
+
log_retry_attempts: True,
29
32
)
30
33
let url = goose.build_url(config)
31
34
···
44
47
max_message_size_bytes: option.None,
45
48
compress: False,
46
49
require_hello: False,
50
+
max_backoff_seconds: 60,
51
+
log_connection_events: True,
52
+
log_retry_attempts: True,
47
53
)
48
54
let url = goose.build_url(config)
49
55
···
62
68
max_message_size_bytes: option.None,
63
69
compress: False,
64
70
require_hello: False,
71
+
max_backoff_seconds: 60,
72
+
log_connection_events: True,
73
+
log_retry_attempts: True,
65
74
)
66
75
let url = goose.build_url(config)
67
76
···
80
89
max_message_size_bytes: option.None,
81
90
compress: False,
82
91
require_hello: False,
92
+
max_backoff_seconds: 60,
93
+
log_connection_events: True,
94
+
log_retry_attempts: True,
83
95
)
84
96
let url = goose.build_url(config)
85
97
···
98
110
max_message_size_bytes: option.Some(1_048_576),
99
111
compress: False,
100
112
require_hello: False,
113
+
max_backoff_seconds: 60,
114
+
log_connection_events: True,
115
+
log_retry_attempts: True,
101
116
)
102
117
let url = goose.build_url(config)
103
118
···
116
131
max_message_size_bytes: option.None,
117
132
compress: True,
118
133
require_hello: False,
134
+
max_backoff_seconds: 60,
135
+
log_connection_events: True,
136
+
log_retry_attempts: True,
119
137
)
120
138
let url = goose.build_url(config)
121
139
···
134
152
max_message_size_bytes: option.None,
135
153
compress: False,
136
154
require_hello: True,
155
+
max_backoff_seconds: 60,
156
+
log_connection_events: True,
157
+
log_retry_attempts: True,
137
158
)
138
159
let url = goose.build_url(config)
139
160
···
152
173
max_message_size_bytes: option.Some(2_097_152),
153
174
compress: True,
154
175
require_hello: True,
176
+
max_backoff_seconds: 60,
177
+
log_connection_events: True,
178
+
log_retry_attempts: True,
155
179
)
156
180
let url = goose.build_url(config)
157
181