A Gleam WebSocket consumer for AT Protocol Jetstream events.

feat: add automatic retry logic to start_consumer (v1.1.0)

- merge retry configuration into JetstreamConfig
- add max_backoff_seconds, log_connection_events, log_retry_attempts fields
- start_consumer now always retries with exponential backoff
- distinguish between harmless timeouts and real connection failures
- update documentation with integrated retry options
- update example and tests

+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
··· 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
··· 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 name = "goose" 2 - version = "1.0.0" 2 + version = "1.1.0" 3 3 description = "A Gleam WebSocket consumer for AT Protocol Jetstream events" 4 4 licences = ["Apache-2.0"] 5 5 repository = { type = "github", user = "bigmoves", repo = "goose" }
+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
··· 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
··· 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