Skip to content

Ban post-delivery retries for mid-body fetch failures - #294

Merged
sroussey merged 7 commits into
mainfrom
claude/fetchurltask-streaming-cache-ey52fj
Aug 15, 2026
Merged

Ban post-delivery retries for mid-body fetch failures#294
sroussey merged 7 commits into
mainfrom
claude/fetchurltask-streaming-cache-ey52fj

Conversation

@sroussey

Copy link
Copy Markdown
Contributor

Summary

Prevents SecFetchJob from retrying fetch attempts after bytes have already been delivered to a stream receiver. Once a consumer's subscription receives body chunks, re-issuing the request from byte 0 would concatenate a second body onto the first, corrupting the stream. This fix applies to both mid-body socket errors and per-attempt timeouts.

Changes

  • SecFetchJob.ts: Added delivery tracking via deliveredToReceiver latch that fires when any delta event (not just binary ones) is emitted to a stream receiver. The retry loop now checks this flag before retrying, preventing concatenation of partial bodies. Includes detailed comments explaining why timeout-driven aborts need special handling since they bypass the normal BODY_TRUNCATED classification.

  • SecFetchJob.test.ts:

    • Added helper functions bodyFailsMidStream() and wholeBody() for testing mid-body failures
    • Added new describe("post-delivery retry ban") test suite with two complementary tests:
      • Verifies a mid-body failure with a stream receiver is not retried (1 attempt, non-retryable error)
      • Verifies a mid-body failure without a stream receiver is still retried (2 attempts, succeeds on retry)
  • SecFetchJobTimeout.test.ts (new file): Isolated test suite for per-attempt timeout behavior, using environment variables to set aggressive timeouts (80ms, 1ms backoff). Tests that:

    • A timeout after bytes reach a receiver is not retried (prevents concatenation)
    • A timeout before any bytes reach a receiver is still retried (allows slow endpoints to succeed)
  • SecCachedFetchTask.ts: Refactored execute() to override resolveFetchInput() instead, moving URL resolution into the proper seam that all dispatch paths (including executeStream()) run through. This ensures the fetch input is correctly resolved regardless of whether the task is streamed or not.

Implementation Details

The delivery check is placed before other retry conditions because timeout-driven aborts have a different error shape than mid-body socket errors. Socket errors are already classified as terminal BODY_TRUNCATED by workglow's body classifier once bytes have gone out, but timeout aborts keep their bare Error/AbortError shape and would otherwise pass through the isRetriableError() check. The delivery latch provides a unified ban for both failure modes.

The deliveredToReceiver flag is latched the moment any delta event is emitted (not just binary deltas), since what makes bytes unrepeatable is that something received them, not the specific event type.

https://claude.ai/code/session_01RFf49R1YHc5s8JAAEhRHyj

@srousseyClaude

Copy link
Copy Markdown
ContributorAuthor

build is red with two errors. One is mine and cannot be fixed here; the other isn't from this PR.

1. SecCachedFetchTask.ts(123,28) — mine, blocked on a libs publish

error TS4113: This member cannot have an 'override' modifier because it is
not declared in the base class 'SecFetchTask<...>'

resolveFetchInput is a new seam added in workglow-dev/libs#790 and not yet published. CI installs @workglow/* from npm (0.3.44), where FetchUrlTask has no such method — hence the error.

Deleting the override modifier would compile and would be worse.noImplicitOverride isn't enabled here (it isn't part of strict), so the file would build — but against published libs nothing ever callsresolveFetchInput, and this PR removed the execute() override that used to do the work. Every SEC fetch would then run with an unresolved input: no inputToUrl, no derived URL. A red build would become a green build that fetches the wrong thing at runtime.

The change is semantically coupled to the unpublished libs seam, by design — libs#790 makes executeStream the sole entry, so a subclass execute() override is no longer dispatched at all (and now throws at construction). Correct order is: land and publish libs#790, then this goes green with no edit.

Tracked in #293.

One thing that could land now, if it's useful

ad22e93 (the timeout fix) is the only commit here addressing a bug that is live today against published libs: SecFetchJob's timedOut branch short-circuits isRetriableError, so a body trickling slower than SEC_FETCH_TIMEOUT_MS retries and re-delivers on the same subscription — measured at 4 attempts, [1,2,3,1,2,3,1,2,3,1,2,3] onto one stream. It latches on emitStreamEvent, which exists in the published API, so it does not depend on the new seam.

If you'd rather not wait for the publish to fix that, say so and I'll split it onto its own branch off main. Otherwise it rides along here.

2. sectionRunner.ts(222,23) — not from this PR

error TS18048: 'fallbacks' is possibly 'undefined'.

Not among this PR's 4 changed files (SecCachedFetchTask.ts, SecFetchJob.ts, SecFetchJob.test.ts, SecFetchJobTimeout.test.ts). It sits at the merge base, so main builds red independently of this branch. Happy to fix it in a separate PR if you want it cleared — it's outside this change's scope.


Generated by Claude Code

@srousseyClaude

Copy link
Copy Markdown
ContributorAuthor

CI on 1fe65c0 is down to one error, and it is not fixable in this repo yet.

src/task/fetch/SecCachedFetchTask.ts(123,28): error TS4113: This member cannot have an
'override' modifier because it is not declared in the base class 'SecFetchTask<...>'

The fallbacks narrowing fix worked — that error is gone.

The 0.3.44 bump does not carry the change this PR needs. The install log confirms it two ways: workglow@0.3.44 resolves, and the error text still prints the base class's response_type?: as optional with the pre-required shape. 0.3.44 was cut before workglow-dev/libs#790 merged (its entries went under ## Unreleased, above the ## 0.3.44 heading). So resolveFetchInput — the seam this PR overrides — does not exist in the published package.

This needs a new libs release containing #790, not a version bump to an existing one. Nothing in sec can close it: the override is correct, and writing it any other way would leave the fetch pointed at the unresolved domain input, since execute() is no longer on the dispatch path for a streamable task.

Two things to expect on the release that unblocks this:

  • response_type becomes required with no default, so every sec fetch call site that omits it will newly fail to typecheck. That migration is the bulk of the remaining plan tasks, not a one-line fix.
  • A persisted job payload carrying no response_type now fails with INVALID_RESPONSE_TYPE before the request is issued, rather than completing successfully with no value. Any queued sec fetch enqueued before that release needs re-enqueueing with an explicit response_type ("stream" reproduces the previous byte-level behaviour).

I'll pick this back up when a libs release lands.


Generated by Claude Code

A sec fetch task's input is a domain key — a CIK, a date, an accession — so the
request was built by overriding execute(). FetchUrlTask no longer calls
execute() on a streamable dispatch, so that override is skipped and the fetch
goes out with whatever unresolved input it was handed; the base class now
refuses the override outright rather than fetching the wrong URL quietly.
resolveFetchInput is the seam it provides for exactly this, and it runs first in
executeStream, so both dispatch paths build the same request. Header merging is
unchanged.
Claude-Session: https://claude.ai/code/session_01RFf49R1YHc5s8JAAEhRHyj
workglow refuses to retry a fetch whose body failed after bytes reached a stream
receiver: the consumer's subscription outlives the attempt, so a re-issue from
byte 0 concatenates onto the partial body and the run reports success over the
join. SecFetchJob runs its OWN retry loop inside execute(), which never sees the
queue's policy — the ban held here only incidentally, through isRetriableError's
`retryable === false` short-circuit, with no test on either side of the boundary.
Measured with the ban removed upstream: 4 attempts instead of 1.
The complement is pinned too. With no emitStreamEvent nothing was delivered
anywhere, so the failure stays retryable and this loop absorbs it — which is
what a large EDGAR download depends on.
Both use a registered safeFetch rather than Bun.serve, so they run under vitest
on node instead of joining the skipped block.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFf49R1YHc5s8JAAEhRHyj
workglow refuses to retry a fetch whose body failed after bytes reached a stream
receiver: the subscription outlives the attempt, so a re-issue from byte 0
concatenates a second body onto the first and the job then reports success over
the join. This loop honored that only through isRetriableError, and the timeout
branch deliberately bypasses isRetriableError — a per-attempt timeout aborts
mid-body and surfaces as AbortSignalJobError, a shape the body classifier passes
through untouched, so it never becomes the terminal BODY_TRUNCATED that stops
every other post-delivery retry.
Measured before the fix, on a 200 whose body trickles slower than the timeout
with a receiver attached: 4 attempts, four bodies concatenated onto one
subscription. That is sec's main large-download path — SecFetchTask always
routes through the queue, JobQueueWorker always supplies emitStreamEvent, and
since execute() drains executeStream() even a non-streamable fetch subscribes.
So the loop now watches its own delivery: emitStreamEvent is wrapped for the
whole job and latches on the first delta, before that emit resolves, and a
latched job never retries. The complement is pinned too — a timeout that fires
before the body loop reads anything delivers nothing, so it still retries with
the receiver attached. What lifts the ban is that no byte reached it, not the
absence of a subscription.
The knobs are read at module load, so the two tests live in their own file with
short values; the rest of the suite keeps the production defaults.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFf49R1YHc5s8JAAEhRHyj
…3.44
The sectionRunner narrowing error is pre-existing on main and blocks the
build independently of this branch. The workglow pin moves to the current
published release; the resolveFetchInput seam this branch overrides still
needs a libs release past 0.3.44.
@sroussey
srousseyforce-pushed the claude/fetchurltask-streaming-cache-ey52fj branch from 1fe65c0 to ba545b1CompareAugust 15, 2026 18:51
…SecFetchAccessionDocTask
Refactored the `guessResponseType` function to simplify response type determination based on file extensions, improving readability and maintainability. Introduced a new utility function to check for materializing response types. Additionally, added unit tests for `SecFetchAccessionDocTask` to verify URL resolution and ensure correct handling of response types, particularly for stream requests. This enhances the robustness of the fetch task implementation.
…ror handling
Refactored the `runCliProcess` function to utilize async/await for better readability and error handling. The function now uses the `once` method from the `events` module to handle process closure, ensuring a cleaner separation of stdout and stderr data collection. This change enhances the overall maintainability of the CLI process execution logic.
…nd add tests
Introduced the `ipoProceeds` function to calculate IPO proceeds based on various inputs, including gross proceeds, price per unit, and units offered. Updated the `processForm424` function to utilize this new calculation method, ensuring accurate handling of cases where unit terms may be missing. Added comprehensive tests for `ipoProceeds` and integrated a new test for `processForm424` to validate the correct extraction of IPO proceeds from the prospectus cover. This enhancement improves the robustness of the Form 424 processing logic.
@sroussey
sroussey merged commit 7adeb63 into mainAug 15, 2026
1 check failed
Sign up for freeto 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.

2 participants

@sroussey@claude