Thanks for lightbeam — the hashlog idempotency model and the structured run metadata are really useful. This is a delivery-gap report with a runnable repro and a suggested fix.
Summary
When state tracking is on (state_dir set), do_post records a payload's hash in the hashlog for a non-2xx response that returns a parseable JSON body — e.g. a 429, or a 5xx that persists across all retries. On a later run, do_send skips any hash already in the hashlog unless meets_process_criteria() says otherwise, and under a plain lightbeam send (no --force, --resend-status-codes, --older-than, or --newer-than) meets_process_criteria() returns False for a stored failure exactly as it does for a stored success. So a record that failed once is indistinguishable from a delivered one and is skipped on every subsequent normal run — it is never re-sent, and no signal separates it from a genuine delivery.
Impact
Records are silently dropped from the ODS on normal re-runs (undelivered, not destroyed — the source .jsonl is untouched). This is most likely under the documented Airflow usage, where re-running a failed task is the normal recovery path — and that plain re-run is exactly the one that skips the failed records. The stored status does enable recovery via --resend-status-codes, but only for an operator who already knows to reach for it.
Version
lightbeam 0.1.12 (repro also present on main @ 1e357bd). Requires state_dir configured (the documented setup that enables the hashlog).
Why 429 is the clean case
The RetryClient uses retry_all_server_errors=True, so 5xx responses are retried in-run (up to num_retries) and a transient 5xx never reaches do_post's handler. A 429, being a 4xx, is not covered by retry_all_server_errors — and (see the related bug below) the configured retry_statuses are dropped — so a 429 passes straight through to the handler, gets its JSON body recorded in the hashlog, and is skipped forever. A 5xx reaches the same fate only if it persists across every retry.
Root cause
In lightbeam/send.py :: Sender.do_post, the whole non-401 branch is guarded by if status != 401: (line 153), so a 401 never reaches the write (it loops back to refresh the token). Inside that branch:
if response.status not in [200, 201]:
response_body = json.loads(body) # a non-JSON body raises JSONDecodeError (a ValueError) -> caught at the
... # `except ValueError` below, so it never reaches the hashlog write
if response.status == 400:
raise ValueError("; ".join(messages)) # 400 short-circuits before the write, too
else:
self.lightbeam.num_errors += 1 # 429 / persistent-5xx / 403 / 409 fall through...
# update hashlog
if self.lightbeam.track_state:
self.hashlog_data[data_hash] = (round(time.time()), response.status) # ...to HERE — written for a non-2xx
So the hashlog write is reached for a terminal non-2xx except a 400, except a 401 (handled separately), and except any response whose body isn't valid JSON (that raises JSONDecodeError, a ValueError, caught below). Then on the next run, do_send (lines ~80-103) skips it, because Lightbeam.meets_process_criteria (lightbeam.py ~237) is:
return ( self.force
or (self.older_than and tuple[0] < self.older_than)
or (self.newer_than and tuple[0] > self.newer_than)
or (len(self.resend_status_codes) > 0 and tuple[1] in self.resend_status_codes) )
With default flags every clause is falsy, so a stored (timestamp, 429) returns False — treated as done. After run 1 the failed record is indistinguishable from a delivered one and is counted in the same num_skipped bucket, so nothing downstream separates the two.
Minimal reproduction (no live API needed)
import types
from lightbeam.lightbeam import Lightbeam
from lightbeam import hashlog
# a plain `lightbeam send` — no --force / --resend-status-codes / --older-than / --newer-than
flags = types.SimpleNamespace(force=False, older_than="", newer_than="", resend_status_codes="")
payload = '{"studentUniqueId": "12345"}'
h = hashlog.get_hash(payload)
# state after run 1: this record got a 429, which do_post wrote to the hashlog
hashlog_after_run_1 = {h: (1_700_000_000, 429)}
print("seen before? ", h in hashlog_after_run_1) # True
print("will resend? ", Lightbeam.meets_process_criteria(flags, hashlog_after_run_1[h])) # False
# => the 429'd record is counted as skipped and never re-sent.
Expected: a record whose last status was a failure is retried on the next run (or at least not recorded as "handled").
Actual: it is stored like a success and skipped on every subsequent plain run.
Suggested fix
Distinguish transient from permanent. A transient failure (429, 5xx) should be resendable by default — ideally retried in-run once the retry bug below is fixed, or at minimum treated as "not done" by meets_process_criteria. A permanent failure (403/409/422) can be recorded but should be surfaced, not silently skipped. The invariant worth enforcing: a record is "done" only once the API has confirmed it.
Related bug (compounding)
api.py:94 builds the retry client as:
statuses=self.lightbeam.config['connection']["retry_statuses"].append(401)
list.append(...) returns None, so statuses=None reaches ExponentialRetry and the configured retry_statuses (429/500/501/503/504) are dropped — a 429 isn't retried at all. It also mutates config['connection']['retry_statuses'] in place (appending 401 on every call). Fixing this so 429/5xx are actually retried removes most of the transient failures that trigger the skip above.
Note
I hit both of these while auditing lightbeam for silent-failure modes; I have runnable tests pinning each and would happily open a PR with fixes + regression tests if useful.
Thanks for lightbeam — the hashlog idempotency model and the structured run metadata are really useful. This is a delivery-gap report with a runnable repro and a suggested fix.
Summary
When state tracking is on (
state_dirset),do_postrecords a payload's hash in the hashlog for a non-2xx response that returns a parseable JSON body — e.g. a429, or a5xxthat persists across all retries. On a later run,do_sendskips any hash already in the hashlog unlessmeets_process_criteria()says otherwise, and under a plainlightbeam send(no--force,--resend-status-codes,--older-than, or--newer-than)meets_process_criteria()returnsFalsefor a stored failure exactly as it does for a stored success. So a record that failed once is indistinguishable from a delivered one and is skipped on every subsequent normal run — it is never re-sent, and no signal separates it from a genuine delivery.Impact
Records are silently dropped from the ODS on normal re-runs (undelivered, not destroyed — the source
.jsonlis untouched). This is most likely under the documented Airflow usage, where re-running a failed task is the normal recovery path — and that plain re-run is exactly the one that skips the failed records. The stored status does enable recovery via--resend-status-codes, but only for an operator who already knows to reach for it.Version
lightbeam
0.1.12(repro also present onmain@1e357bd). Requiresstate_dirconfigured (the documented setup that enables the hashlog).Why 429 is the clean case
The
RetryClientusesretry_all_server_errors=True, so5xxresponses are retried in-run (up tonum_retries) and a transient 5xx never reachesdo_post's handler. A429, being a 4xx, is not covered byretry_all_server_errors— and (see the related bug below) the configuredretry_statusesare dropped — so a429passes straight through to the handler, gets its JSON body recorded in the hashlog, and is skipped forever. A5xxreaches the same fate only if it persists across every retry.Root cause
In
lightbeam/send.py :: Sender.do_post, the whole non-401 branch is guarded byif status != 401:(line 153), so a 401 never reaches the write (it loops back to refresh the token). Inside that branch:So the hashlog write is reached for a terminal non-2xx except a 400, except a 401 (handled separately), and except any response whose body isn't valid JSON (that raises
JSONDecodeError, aValueError, caught below). Then on the next run,do_send(lines ~80-103) skips it, becauseLightbeam.meets_process_criteria(lightbeam.py ~237) is:With default flags every clause is falsy, so a stored
(timestamp, 429)returnsFalse— treated as done. After run 1 the failed record is indistinguishable from a delivered one and is counted in the samenum_skippedbucket, so nothing downstream separates the two.Minimal reproduction (no live API needed)
Expected: a record whose last status was a failure is retried on the next run (or at least not recorded as "handled").
Actual: it is stored like a success and skipped on every subsequent plain run.
Suggested fix
Distinguish transient from permanent. A transient failure (429, 5xx) should be resendable by default — ideally retried in-run once the retry bug below is fixed, or at minimum treated as "not done" by
meets_process_criteria. A permanent failure (403/409/422) can be recorded but should be surfaced, not silently skipped. The invariant worth enforcing: a record is "done" only once the API has confirmed it.Related bug (compounding)
api.py:94builds the retry client as:list.append(...)returnsNone, sostatuses=NonereachesExponentialRetryand the configuredretry_statuses(429/500/501/503/504) are dropped — a429isn't retried at all. It also mutatesconfig['connection']['retry_statuses']in place (appending401on every call). Fixing this so429/5xxare actually retried removes most of the transient failures that trigger the skip above.Note
I hit both of these while auditing lightbeam for silent-failure modes; I have runnable tests pinning each and would happily open a PR with fixes + regression tests if useful.