Webhook responses read up to 64 KiB; at most 1000 waiting per hook c98935537c57
A delivery reads the endpoint's answer in chunks and stops at 64 KiB (its first 2000 characters are kept, as before), so an endpoint cannot fill the hub's memory; a hook with 1000 deliveries waiting takes no more events until it catches up (each dropped one logged). Deliveries already go out one at a time from one worker. Closes #220
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
Claude Code <[email protected]> · delegate for changes, until 2026-11-13 19:00Z · key ea5f78160088
- When
- · 2026-10-15 23:28 UTC
- Signature
- ✔ signed by Claude Code
- Hash
c98935537c57e7c0593190befffff62863149107aaae01388498c3ee82614157- Depends on
- 9756c67d3aec b64e2537d702
- Depended on by
- 3b1bc27fe805 5d90e0e045e2
- In bookmarks
- api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
- In tags
- v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 9 atoms, 0 tree operations, 3 files
Browse the tree at this change
modified crates/hub/src/db/hooks.rs +16 −1
| … | 166 unchanged lines |
||
| 167 | 167 | Ok(()) |
|
| 168 | 168 | } |
|
| 169 | 169 | ||
| 170 | - | pub async fn queue_delivery(pool: &Pool, hook_id: i64, delivery: &str, event: &str, payload: &str) -> sqlx::Result<i64> { |
|
| 170 | + | /// The most deliveries one hook may have waiting (#220): beyond it, new |
|
| 171 | + | /// events for that hook are dropped (and logged) until it catches up. |
|
| 172 | + | pub const MAX_WAITING: i64 = 1000; |
|
| 173 | + | ||
| 174 | + | /// Queue a delivery; `Ok(None)` when the hook already has |
|
| 175 | + | /// [`MAX_WAITING`] deliveries waiting, so nothing was queued. |
|
| 176 | + | pub async fn queue_delivery(pool: &Pool, hook_id: i64, delivery: &str, event: &str, payload: &str) -> sqlx::Result<Option<i64>> { |
|
| 177 | + | let waiting: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM deliveries WHERE hook_id = ? AND delivered_ms IS NULL AND next_ms < ?") |
|
| 178 | + | .bind(hook_id) |
|
| 179 | + | .bind(i64::MAX / 4) |
|
| 180 | + | .fetch_one(pool) |
|
| 181 | + | .await?; |
|
| 182 | + | if waiting >= MAX_WAITING { |
|
| 183 | + | return Ok(None); |
|
| 184 | + | } |
|
| 171 | 185 | let now = now_ms(); |
|
| 172 | 186 | sqlx::query_scalar( |
|
| 173 | 187 | "INSERT INTO deliveries (hook_id, delivery, event, payload, created_ms, next_ms) VALUES (?, ?, ?, ?, ?, ?) RETURNING id", |
|
| … | 6 unchanged lines |
||
| 180 | 194 | .bind(now) |
|
| 181 | 195 | .fetch_one(pool) |
|
| 182 | 196 | .await |
|
| 197 | + | .map(Some) |
|
| 183 | 198 | } |
|
| 184 | 199 | ||
| 185 | 200 | pub async fn due_deliveries(pool: &Pool, limit: i64) -> sqlx::Result<Vec<Delivery>> { |
|
| … | 120 unchanged lines |
modified crates/hub/src/hooks.rs +31 −4
| … | 37 unchanged lines |
||
| 38 | 38 | data: data.clone(), |
|
| 39 | 39 | }; |
|
| 40 | 40 | let payload = serde_json::to_string(&envelope).unwrap_or_default(); |
|
| 41 | - | if let Err(e) = hdb::queue_delivery(&app.db, h.id, &delivery, event, &payload).await { |
|
| 42 | - | tracing::warn!("queueing delivery: {e}"); |
|
| 41 | + | match hdb::queue_delivery(&app.db, h.id, &delivery, event, &payload).await { |
|
| 42 | + | Ok(Some(_)) => {} |
|
| 43 | + | Ok(None) => tracing::warn!("webhook {} has {} deliveries waiting: {event} not queued", h.id, hdb::MAX_WAITING), |
|
| 44 | + | Err(e) => tracing::warn!("queueing delivery: {e}"), |
|
| 43 | 45 | } |
|
| 44 | 46 | } |
|
| 45 | 47 | app.events.notify_deliveries(); |
|
| … | 150 unchanged lines |
||
| 196 | 198 | { |
|
| 197 | 199 | Ok(r) => { |
|
| 198 | 200 | let status = r.status().as_u16() as i64; |
|
| 199 | - | let text = r.text().await.unwrap_or_default(); |
|
| 200 | - | (Some(status), text) |
|
| 201 | + | (Some(status), capped_text(r, RESPONSE_LIMIT).await) |
|
| 201 | 202 | } |
|
| 202 | 203 | Err(e) => (None, e.to_string()), |
|
| 203 | 204 | }; |
|
| … | 2 unchanged lines |
||
| 206 | 207 | n |
|
| 207 | 208 | } |
|
| 208 | 209 | ||
| 210 | + | /// How much of a webhook's response is read (#220): enough to show why a |
|
| 211 | + | /// delivery failed, never so much that an endpoint can fill memory. |
|
| 212 | + | const RESPONSE_LIMIT: usize = 64 * 1024; |
|
| 213 | + | ||
| 214 | + | /// A response's body as text, read in chunks and stopped at `limit` bytes. |
|
| 215 | + | async fn capped_text(mut r: reqwest::Response, limit: usize) -> String { |
|
| 216 | + | let mut body = Vec::new(); |
|
| 217 | + | while let Ok(Some(chunk)) = r.chunk().await { |
|
| 218 | + | let room = limit - body.len(); |
|
| 219 | + | body.extend_from_slice(&chunk[..chunk.len().min(room)]); |
|
| 220 | + | if body.len() >= limit { |
|
| 221 | + | break; |
|
| 222 | + | } |
|
| 223 | + | } |
|
| 224 | + | String::from_utf8_lossy(&body).into_owned() |
|
| 225 | + | } |
|
| 226 | + | ||
| 209 | 227 | /// The background worker: delivers when nudged and every minute anyway. |
|
| 210 | 228 | pub async fn worker(app: Shared) { |
|
| 211 | 229 | let mut rx = app.events.deliveries.subscribe(); |
|
| … | 11 unchanged lines |
||
| 223 | 241 | #[cfg(test)] |
|
| 224 | 242 | mod tests { |
|
| 225 | 243 | use super::*; |
|
| 244 | + | ||
| 245 | + | #[tokio::test] |
|
| 246 | + | async fn a_response_is_read_only_up_to_the_limit() { |
|
| 247 | + | // #220: an endpoint answering 100 KiB is read to 64 KiB, no further. |
|
| 248 | + | let big = reqwest::Response::from(axum::http::Response::new("x".repeat(100 * 1024))); |
|
| 249 | + | assert_eq!(capped_text(big, RESPONSE_LIMIT).await.len(), RESPONSE_LIMIT); |
|
| 250 | + | let small = reqwest::Response::from(axum::http::Response::new("ok".to_string())); |
|
| 251 | + | assert_eq!(capped_text(small, RESPONSE_LIMIT).await, "ok"); |
|
| 252 | + | } |
|
| 226 | 253 | ||
| 227 | 254 | fn envelope(event: &str, data: serde_json::Value) -> String { |
|
| 228 | 255 | serde_json::to_string(&WebhookEnvelope { |
|
| … | 73 unchanged lines |
modified crates/hub/tests/api.rs +23 −0
| … | 519 unchanged lines |
||
| 520 | 520 | let url = rx.url.replace(':', "%3A").replace('/', "%2F"); |
|
| 521 | 521 | assert!(form(format!("_csrf={csrf}&url={url}&format=discord&events=*")).await.contains("Webhook added")); |
|
| 522 | 522 | let hook = oxygenhub::db::hooks::hooks_for_repo(&app.db, 1).await.unwrap().remove(0); |
|
| 523 | + | // #220: a hook with MAX_WAITING deliveries waiting takes no more until it catches up. |
|
| 524 | + | { |
|
| 525 | + | use oxygenhub::db::hooks::{queue_delivery, MAX_WAITING}; |
|
| 526 | + | let before: i64 = |
|
| 527 | + | sqlx::query_scalar("SELECT COUNT(*) FROM deliveries WHERE hook_id = ?").bind(hook.id).fetch_one(&app.db).await.unwrap(); |
|
| 528 | + | for i in 0..MAX_WAITING { |
|
| 529 | + | sqlx::query( |
|
| 530 | + | "INSERT INTO deliveries (hook_id, delivery, event, payload, created_ms, next_ms) VALUES (?, ?, 'push', '{}', 0, 0)", |
|
| 531 | + | ) |
|
| 532 | + | .bind(hook.id) |
|
| 533 | + | .bind(format!("flood-{i}")) |
|
| 534 | + | .execute(&app.db) |
|
| 535 | + | .await |
|
| 536 | + | .unwrap(); |
|
| 537 | + | } |
|
| 538 | + | assert_eq!(queue_delivery(&app.db, hook.id, "one-more", "push", "{}").await.unwrap(), None, "the queue is full"); |
|
| 539 | + | sqlx::query("DELETE FROM deliveries WHERE delivery LIKE 'flood-%'").execute(&app.db).await.unwrap(); |
|
| 540 | + | assert!(queue_delivery(&app.db, hook.id, "now-room", "push", "{}").await.unwrap().is_some()); |
|
| 541 | + | sqlx::query("DELETE FROM deliveries WHERE delivery = 'now-room'").execute(&app.db).await.unwrap(); |
|
| 542 | + | let after: i64 = |
|
| 543 | + | sqlx::query_scalar("SELECT COUNT(*) FROM deliveries WHERE hook_id = ?").bind(hook.id).fetch_one(&app.db).await.unwrap(); |
|
| 544 | + | assert_eq!(before, after); |
|
| 545 | + | } |
|
| 523 | 546 | assert_eq!(hook.format, "discord"); |
|
| 524 | 547 | // Ping arrives as a Discord message. |
|
| 525 | 548 | form(format!("_csrf={csrf}&id={}&action=ping", hook.id)).await; |
|
| … | 55 unchanged lines |