Fix export_subscribers aborting on the export's pending poll - #26
Merged
Conversation
`export_subscribers` failed on every call, with any arguments (#25). The cause is the poll, not the query. While Substack generates the file, `GET /subscriber_set/export/<id>` answers `400 {"error":"Export not ready"}` — not a 200 with an absent `url`, which is what this repo believed and what the suite mocked. Since the tool's first poll is immediate and generation takes ~3s even for 6 rows, every export hit that 400 and `readBody` turned it into a fatal error. Measured live 2026-09-03, polling a fresh 6-row export at 250ms: +0.17s 400 Export not ready ... 400 Export not ready (7 polls) +2.71s 400 Export not ready +3.29s 200 {"url":"…/file"} A 200 without a `url` was never observed at any point. `getSubscriberSetExport` now translates that one body into `{pending: true}` and rethrows every other 400 — an export that will never arrive has to fail now rather than poll out the caller's wait budget. The tool needed no change: `{pending: true}` carries no `url`, so the existing loop, backoff, budget and timeout message work unchanged, and an absent `url` still means retry in case that state ever exists. `readBody` also attaches `status` and `body` to the error it throws. The message carried only the status, so Substack's own explanation of a refusal was readable in the log and nowhere else, and no caller could tell one 400 from another — which is why this presented as a bare `400 Bad Request` naming none of the four steps. Note what hid it: the pending state was mocked as `200 {}`, so 748 tests stayed green while the tool failed 100% of the time in production. The mock described the wrong universe, so no assertion over it could have failed. CLAUDE.md gains the two rules that follow — a fixture representing an API state must cite where that state was observed, and a time-dependent flow is verified by running the real handler, never by hand, because curl-by-curl runs at human pace and cannot enter a 3-second window. Verified live end to end after the fix: two pending polls, then the CSV. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
marcomoauro
added a commit
that referenced
this pull request
Sep 3, 2026
Follow-up to #26, found while verifying it live. `CLAUDE.md`, the tool's own description and a passing test all claimed **two** columns cannot be exported. Measured live 2026-09-03 against a real publication: ``` requested : 48 returned : 47 missing : ['tag_ids'] group_membership present: true (value "None") ``` A narrower probe confirms it directly: requesting `['group_membership','tag_ids','activity_rating']` returns `['activity_rating','group_membership']` with `missing_columns: ['tag_ids']`. Whether Substack changed or the original reading was wrong cannot be told apart after the fact. That is the argument for what the tool already does — diff the returned header against the requested list, rather than carry a static list of undeliverable columns. **That mechanism is correct and untouched**; only the claims about it were wrong, and the `missing_columns` guard is still needed for `tag_ids`. ## Why the description mattered most `src/server.js` is where a model reads whether the data it needs is obtainable at all, before it ever sees the schema. Telling it `group_membership` cannot be exported means it stops asking for a column that arrives fine — the same failure mode as the `upload_image` pitch that still said "from an http(s) URL" after `path` was added. ## How it survived The suite's CSV fixture omitted the `Group membership` header, so the false claim had a **green test proving it**. That is the same shape as the pending-poll mock in #26, and precisely what the fixture rule added there exists to catch: > A fixture that represents an API *state* must cite where that state was observed. The fixture now carries the header, the value `None` as returned, and the date of the measurement. ## Verification - Live measurement above, twice, at two different column widths. - **Mutation-checked**: removing `Group membership` from the fixture header again fails 3 tests (`reports the columns it actually got back`, `names the requested columns that never came back`, `records each step of the flow`). Confirmed the mutation landed before trusting it. - `753 pass / 0 fail` on both supported runtimes. - `tools/list` re-read from the real entrypoint: the published description now ends "One column cannot be exported and is reported in `missing_columns` rather than failing: tag_ids." 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #25.
export_subscribersfailed on every call, with any arguments. The reported symptom is real and 100% reproducible; the reported cause is not the one.The cause is the poll, not the query
While Substack generates the file,
GET /subscriber_set/export/<id>answers400 {"error":"Export not ready","type":"single"}— not a 200 with an absenturl, which is what this repo believed and what the suite mocked. The tool's first poll is immediate and generation takes ~3s even for 6 rows, so every export hit that 400 andreadBodyturned it into a fatal error.Measured live 2026-09-03, polling a fresh 6-row export at 250ms:
A
200with an absenturlwas never observed at any point. The polling condition tested a state that does not exist, while the state that does exist threw.The failure rate is 100% rather than intermittent because of a conjunction: the first poll is immediate and any non-2xx is fatal. Either alone would have been harmless — the optimisation meant to save one second is what guarantees the first poll lands inside the 400 window.
What the issue got wrong, for the record
The reported cause —
buildSubscriberQuerypassingsort_directiontocreateSubscriberSet— is not reachable.export_subscriberstakes only thefiltersobject and drops the rest (export_subscribers.js:111),sort_directionis never part of that function's output, and the tool's schema does not expose sorting at all. Live:POST /subscriber_setwith{"query":{"activity_rating_gte":4}}→200. Intercepting a real no-argument call gives{"query":{}}, the body the issue itself lists as 200. A test already pinned that body (export_subscribers.spec.js:122). Acting on the stated cause would have changed nothing and left the tool broken.The change
getSubscriberSetExporttranslates that one body into{pending: true}and rethrows every other 400 — an export that will never arrive has to fail now rather than poll out the caller's wait budget.readBodyattachesstatusandbodyto the error it throws. The message carried only the status, so Substack's explanation of a refusal was readable in the log and nowhere else, and no caller could tell one 400 from another. That is why this presented as a bare400 Bad Requestnaming none of the four steps — and, I'd argue, why the issue's diagnosis went wrong: an uninformative error does not just slow diagnosis down, it invites a plausible wrong one.export_subscribers.jsis unchanged:{pending: true}carries nourl, so the existing loop, backoff, budget and timeout message all work as they are, and an absenturlstill means retry in case that state ever exists.Verification
SubstackAPIException: 400 Bad Request:status/bodyon the error.753 pass / 0 failon both supported runtimes (theenginesfloor, 22, and.nvmrc, 24).Why 748 green tests missed it
The pending state was mocked as
200 {}, so the suite stayed green while the tool failed every time in production. That mock described the wrong universe, so no assertion over it could have failed however thorough it was — the mocks are ours, so the suite measures this repo's model of Substack and never Substack.CLAUDE.mdtherefore gains the two rules that follow, next to "A new test that passes on the first run has proven nothing":Out of scope, noted while verifying
group_membershipdoes export (measured: 46 columns requested → 45 returned, onlytag_idsmissing).CLAUDE.mdand the tool's own description still claim two columns are undeliverable, which misleads a calling model. Left for a separate change rather than mixed into a bugfix.🤖 Generated with Claude Code