Skip to content

Retry batches answered with 429 or 5xx, and report rejected batches once - #55

Merged
PetrHeinz merged 8 commits into
mainfrom
claude/retry-and-report-rejected-batches
Oct 2, 2026
Merged

PetrHeinz merged 8 commits into
mainfrom
claude/retry-and-report-rejected-batches

Conversation

@PetrHeinz

@PetrHeinz PetrHeinz commented Oct 1, 2026 •

Copy link
Copy Markdown
Member

The HTTP outlet treats any response as a delivery (deliver_requests). A 429 or 5xx is never retried and its Retry-After is ignored, and a 401, 403 or 413 drops the batch without a word. Only exceptions are retried, and since 0.1.20 any response, a 503 included, also resets the reconnect backoff. The red-team found that a wrong source token loses 100% of the logs with no output at all.

  • 2xx: delivered, and the reconnect backoff starts over, as before.
  • 408, 429 and 5xx: the batch goes back on the queue and is retried like after a failed connection, with the same backoff (1 s, 2 s, 4 s, … up to 30 s) and the same limit of 3 attempts. A Retry-After header (seconds, or an HTTP date) sets the minimum wait, capped at 60 s. These responses don't reset the backoff.
  • Any other status (401, 403, 413 and the other 4xx, also 3xx): the batch is dropped, and the first rejection with each status code in a process prints one warning through Kernel#warn, never through a Logtail logger. For example: Logtail: Better Stack rejected 37 log lines with HTTP 401 Unauthorized - check your source token. Further rejections with this status won't be reported.
  • The Logtail::Config debug output stays. It now calls any 2xx a success, not just 202.
  • The retry-or-drop logic for exceptions moved into a helper that both paths use.

Behaviour and compatibility:

  • Delivery is at-least-once: if a 5xx comes back after Better Stack already stored the batch, the retry stores it again and those lines are duplicated.
  • While the ingest answers 5xx, a batch waits in the request queue between its attempts, as it does while the host can't be reached, and flush/close wait for it the same way. Against a local stub answering 503 at exit, close took 2 s (three attempts); with Retry-After: 60 it waited its full 20 s limit, as for an unreachable host. The shutdown PR (claude/fast-quiet-shutdown) shortens that wait.
  • Rejections now print a line on stderr, once per status code and process. ruby -W0 silences it, like any warn.
  • RequestAttempt takes an optional line count for the warning.

Follow-up after the E2E run of the release candidate (two more commits, the first one only a test, red on CI): Better Stack's ingesting proxy answers 408 Request Time-out, and closes the connection, when a new connection stays unused for more than about 10 to 15 seconds. The outlet opens a new connection after every requests_per_conn requests and then waits for lines, so after a quiet spell the next batch got that 408 and was dropped with a misleading "rejected … with HTTP 408" warning; 0.1.20 lost it silently. A 408 means the server didn't read the request, so it's now retried like a 429 or 5xx, with the same backoff and attempt limit, honouring Retry-After, and without a warning.

Targets the logtail 0.1.21 patch release. No dependencies on the other open PRs; the fork-safety and shutdown PRs change other methods of http.rb.

The first commit only adds the tests and is expected to fail on CI; the fix follows in the next commit.

🤖 Generated with Claude Code

PetrHeinz and others added 5 commits October 1, 2026 18:41
The HTTP outlet treats every response as delivered: 429 and 5xx are never
retried, Retry-After is ignored, and 401 or 413 drop the batch without a word.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Under `ruby -w`, warnings from other code reach stderr while the outlet runs
and broke the exact stderr matches. Expect the warning through `warn` instead,
and match the 413 one as a line of stderr.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
deliver_requests treated every response as a delivery. Now:
- 2xx: delivered, the reconnect wait starts over.
- 429 and 5xx: the request goes back on the queue like after a failed
  connection, with the same backoff and limit of 3 attempts, without starting
  the wait over. Retry-After (seconds or an HTTP date) sets the minimum wait,
  capped at 60 s.
- Any other status: the batch is dropped, and the first rejection with each
  status in the process prints a warning through Kernel#warn, e.g. "Logtail:
  Better Stack rejected 37 log lines with HTTP 401 Unauthorized - check your
  source token. Further rejections with this status won't be reported."

RequestAttempt takes the batch's line count for the warning, and the
retry-or-drop logic moved into a helper that the exception path uses too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Better Stack's ingesting proxy answers 408 Request Time-out, and closes the
connection, when a new connection stays unused for more than about 10 to 15
seconds. The outlet opens a new connection after every requests_per_conn
requests and then waits for lines, so after a quiet spell the next batch
gets that 408. It is dropped with a warning that Better Stack rejected it,
although the server never read the request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A 408 means the server didn't read the request, so the batch is kept and
retried with the same backoff and attempt limit, waiting as long as a
Retry-After header asks, and no rejection is reported.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@PetrHeinz
PetrHeinz marked this pull request as ready for review October 2, 2026 09:07
PetrHeinz and others added 3 commits October 2, 2026 13:43
Conflicts in lib/logtail/log_devices/http.rb, both sides kept:
- require block: #54's require "set" and this branch's require "time"
- constants after MAX_RECONNECT_WAIT: this branch's MAX_RETRY_AFTER,
  REPORTED_REJECTIONS and REPORTED_REJECTIONS_LOCK, then
  SYNCHRONOUS_DELIVERY_TIMEOUT from #57/#58

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ivery

With #57 and #58 merged, a delivery in the calling thread (flush without an
outlet thread, lines written after close or during shutdown) counts any
HTTP response as delivered. So a batch rejected with 401 or 403 isn't
reported, and lines written after close keep going out after a 429 or 5xx.
Three of the four new examples fail for that. The fourth, a batch answered
with 408, 429 or 5xx is neither retried nor reported, passes already and
keeps the fix from reporting those statuses as rejected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…very

deliver_synchronously now counts only 2xx as delivered. 408, 429 and 5xx
are logged at debug level and not retried, since nothing would deliver the
retry; any other status is dropped and reported once per status through
report_rejected_batch. It takes the RequestAttempts, so the warning can
count the lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@PetrHeinz
PetrHeinz merged commit 1060183 into main Oct 2, 2026
12 checks passed
@PetrHeinz
PetrHeinz deleted the claude/retry-and-report-rejected-batches branch October 2, 2026 11:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant