Automated fix for openai-usage-pipeline — fix_class code_fix, scope tier draft.
Resolves https://github.com/AI-Builder-Team/Surtr/issues/1601
> Ready for review — verification is green; HEIMDALL_READY_PRS opens verified tier-draft fixes ready for review. A human still merges — auto-merge never applies outside tier auto.
## What's broken
Run e9b62caa-e756-45ec-a3b3-17fc99de13ce of openai-usage-pipeline completed as outcome=partial (442 rows across 28 BUs) after the OpenAI /v1/organization/costs call exhausted its 5-attempt retry budget on HTTP 429 for three BUs: "Line-item cost fetch failed for BU Trilogy-Crossover-PROD, API key 1: 429 Client Error: Too Many Requests for url: https://api.openai.com/v1/organization/costs?...group_by=line_item&limit=180. Persisting the 8 usage record(s) with $0 billed cost; billed dollars self-heal on the next T-2 re-pull." (same for Trilogy-Academics, 78 records, and Trilogy-CNU-Innovations, 23 records). The data cost is worse than the log line claims: the except block at pipelines/runners/openai-usage-pipeline/src/handler.py:263-270 sets only bu_cost_fetch_failed and does NOT set bu_has_error, so the BU still gets its full bu_owned_windows and is published atomically — the log shows "BU Trilogy-Crossover-PROD Redshift: deleted=5, inserted=8", i.e. 5 previously-loaded rows carrying real billed dollars were DELETEd and replaced with 8 rows whose billed_cost_dollars is 0.0. So this run did not merely fail to add cost data; it overwrote correct cost data in staging_finance_ai_spend.raw_openai_token_usage with zeroes for 109 rows across three BUs.
Root cause. The only recovery a rate-limited /costs call gets is the in-request retry loop in _request_with_retries (src/openai_client.py:131-173), and that entire budget is spent inside the BU's single turn of the main loop: attempts at +2s, +4s, +8s and +65s (the RATE_LIMIT_WINDOW floor added by PR #1395), then raise_for_status(). When the org's rolling quota is saturated for longer than that — which it is here, because the shared token bucket is configured at exactly OpenAI's advertised ceiling (OPENAI_MAX_REQUESTS_PER_MINUTE=30 in pipeline.json:33, with a full 30-token burst allowance, so the client sustains requests right at the limit with zero headroom) — the BU is abandoned with line_item_costs = [] and every row gets billed_cost_dollars=0.0 via allocate_billed_to_keys. Nothing in the run ever revisits that BU, even though later BUs in the same run (e.g. Trilogy-Skyvera at 07:09:20) recovered from identical 429s, which proves the throttle clears within the run. The billed dollars self-heal on the next T-2 re-pull comment at src/handler.py:267 is only half true: the default window is [T-2, T) (src/handler.py:94-95), so tomorrow's run re-covers report_date 2026-08-29 but never re-covers 2026-08-28 — those zeroed rows are terminal for scheduled runs and need a manual backfill. The pure-throttling half of this is already addressed by open PR #1595 (commit dbb47285, adds OPENAI_RATE_LIMIT_SAFETY_FACTOR/OPENAI_MAX_BURST_REQUESTS); it is unmerged, and even once merged it only makes 429 exhaustion rarer rather than recoverable, so the missing in-run recovery is the remaining root cause.
## What this PR changes
Add an end-of-run cost catch-up pass to pipelines/runners/openai-usage-pipeline/src/handler.py, mirroring the approach already reviewed for the sibling pipeline in PR #1600 (openai-cost-pipeline) but narrowed to the cost endpoint exactly as the observer recommends — re-pulling only /costs costs one request per affected key instead of the ~10 a full BU re-process would spend, which matters when request volume is itself the failure. Concretely: when the except at src/handler.py:263 fires, retain the affected (bu, key, usage_records, enriched records, cost_pages) so the run can come back to it; after the main BU loop, sleep a cool-down that clears OpenAI's rolling window (new OPENAI_COST_RETRY_DELAY_SECONDS, ~90s), re-call fetch_line_item_costs for those keys only, and on success re-run allocate_billed_to_keys/split_billed_across_records, write the real billed_cost_dollars onto the retained records, and re-publish that BU with insert_usage_records(..., atomic=True, owned_windows=...) so the idempotent DELETE+INSERT replaces the $0 rows with billed ones. Guard the pass with a _remaining_seconds(context) check against a reserve (PR #1600's _remaining_seconds helper is the pattern) so it can never turn a partial run into a Lambda timeout — this run used 590s of its 900s budget, leaving ample room for a 90s cool-down plus three single-request re-pulls — and run it BEFORE the manifest/ledger write so a recovered BU is not recorded as outcome=partial. Report the pass in output_summary as cost_retry_pass ({attempted, recovered, still_failed, skipped}) and keep cost_fetch_failed_bus populated only for BUs that failed twice, so a recovered BU stops firing this CRITICAL finding while a genuinely-dead one stays loud. Add unit tests in tests/test_handler.py covering: cost fetch failing then succeeding on the catch-up (rows end with non-zero billed cost, partial_failure False), failing twice (behaviour unchanged, still partial), and the pass being skipped when the invocation budget is short.
Why this fixes it. This is a silent data failure of exactly the kind Surtr's conventions call out first: the run reported partial and 442 rows published while quietly regressing 109 rows' billed dollars to $0 and permanently losing 2026-08-28's billed cost for three BUs, so a finance consumer of staging_finance_ai_spend.raw_openai_token_usage reads understated spend with no missing-row signal. The change is confined to the pipeline's own directory (handler.py, pipeline.json env, tests), touches no shared code and no SQL, and reuses the pipeline's existing atomic owned-window publication so the re-publish is idempotent rather than a new write path — the Surtr rule against widening a fix into SQL rewrites is why the fix re-pulls and republishes instead of attempting a merge that preserves prior billed values in place. It is deliberately complementary to, not a duplicate of, open PR #1595: that PR lowers the odds of 429 exhaustion by giving the token bucket headroom, while this one makes an exhaustion that still happens recoverable inside the same run; the two are independent and neither conflicts with the other's diff (PR #1595 touches only src/openai_client.py and its tests).
### Files changed
.../runners/openai-usage-pipeline/pipeline.json | 4 +-.../runners/openai-usage-pipeline/src/handler.py | 360 ++++++++++++++++++++-
.../openai-usage-pipeline/tests/conftest.py | 13 +
.../openai-usage-pipeline/tests/test_handler.py | 190 ++++++++++-
.../tests/test_write_modes.py | 109 +++++++
5 files changed, 657 insertions(+), 19 deletions(-)
## Verification
### pytest (pipelines/runners/openai-usage-pipeline/tests) — exit 0
``
dows_still_deletes PASSED [ 82%]
tests/test_redshift_handler.py::TestAtomicPublish::test_atomic_owned_windows_merge_with_row_derived_pairs PASSED [ 83%]
tests/test_redshift_handler.py::TestAtomicPublish::test_atomic_invalid_owned_windows_are_skipped PASSED [ 84%]
tests/test_redshift_handler.py::TestAtomicPublish::test_empty_rows_without_owned_windows_is_a_noop PASSED [ 84%]
tests/test_secrets.py::TestGetOpenAiBuKeys::test_returns_bu_key_mapping PASSED [ 85%]
tests/test_secrets.py::TestGetOpenAiBuKeys::test_normalizes_single_key_to_list PASSED [ 86%]
tests/test_secrets.py::TestGetOpenAiBuKeys::test_raises_on_secrets_manager_error PASSED [ 86%]
tests/test_write_modes.py::TestWriteModes::test_old_mode_has_zero_secondary_side_effects PASSED [ 87%]
tests/test_write_modes.py::TestWriteModes::test_dual_mode_primary_first_then_secondary_lane_then_ledger PASSED [ 88%]
tests/test_write_modes.py::TestWriteModes::test_dual_mode_secondary_failure_is_partial_and_primary_intact PASSED [ 88%]
tests/test_write_modes.py::TestWriteModes::test_dual_mode_ledger_failure_is_partial PASSED [ 89%]
tests/test_write_modes.py::TestWriteModes::test_new_mode_writes_only_secondary_and_failures_raise PASSED [ 90%]
tests/test_write_modes.py::TestWriteModes::test_new_mode_cost_catch_up_republishes_atomically_and_replaces_manifest_entry PASSED [ 90%]
tests/test_write_modes.py::TestWriteModes::test_run_id_falls_back_to_lambda_request_id PASSED [ 91%]
tests/test_write_modes.py::TestWriteModes::test_dual_mode_incomplete_run_is_never_ledgered_as_published PASSED [ 92%]
tests/test_write_modes.py::TestWriteModes::test_invalid_mode_fails_loud PASSED [ 92%]
tests/test_write_modes.py::TestValidEmptyConvergence::test_valid_empty_fetch_converges_window_and_ledgers_zero_published[dual] PASSED [ 93%]
tests/test_write_modes.py::TestValidEmptyConvergence::test_valid_empty_fetch_converges_window_and_ledgers_zero_published[new] PASSED [ 94%]
tests/test_write_modes.py::TestValidEmptyConvergence::test_failed_bu_window_is_never_deleted[dual] PASSED [ 94%]
tests/test_write_modes.py::TestValidEmptyConvergence::test_failed_bu_window_is_never_deleted[new] PASSED [ 95%]
tests/test_write_modes.py::TestValidEmptyConvergence::test_no_bus_path_has_no_secondary_side_effects PASSED [ 96%]
tests/test_write_modes.py::TestLedgerModule::test_record_publication_inserts_row PASSED [ 96%]
tests/test_write_modes.py::TestLedgerModule::test_record_publication_ …_(truncated)_
<details>
<summary>Run metadata</summary>
| Field | Value |
| --- | --- |
| Pipeline | openai-usage-pipeline |
| Failing run | e9b62caa-e756-45ec-a3b3-17fc99de13ce |
| Occurrence | 1 (times this exact failure signature has been seen) |
| Signature | 4bec99fa0b9a809adddc95de596e0b155f5647aa90d901db0ef441ed9bc93655 |
| Verify | green |
</details>
---
🤖 Opened by heimdall. mercy reviews this PR automatically; heimdall revises on REQUEST_CHANGES (bounded rounds). Tier-auto PRs may auto-merge on mercy approval when the consumer enables it; everything else waits for a human. Mention heimdall in a comment to direct it, or add the manual-dev` label to take the PR over and stop it entirely.