OXYGENHUB

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