Skip to content

Fix export_subscribers aborting on the export's pending poll - #26

Merged
marcomoauro merged 1 commit into
mainfrom
fix/export-subscribers-pending-poll
Sep 3, 2026
Merged

Fix export_subscribers aborting on the export's pending poll#26
marcomoauro merged 1 commit into
mainfrom
fix/export-subscribers-pending-poll

Conversation

@marcomoauro

Copy link
Copy Markdown
Owner

Fixes #25.

export_subscribers failed 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> answers 400 {"error":"Export not ready","type":"single"}not a 200 with an absent url, 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 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":"/api/v1/subscriber_set/export/…/file"}

A 200 with an absent url was 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 — buildSubscriberQuery passing sort_direction to createSubscriberSet — is not reachable. export_subscribers takes only the filters object and drops the rest (export_subscribers.js:111), sort_direction is never part of that function's output, and the tool's schema does not expose sorting at all. Live: POST /subscriber_set with {"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

  • getSubscriberSetExport 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.
  • readBody attaches status and body to 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 bare 400 Bad Request naming 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.js is unchanged: {pending: true} carries no url, so the existing loop, backoff, budget and timeout message all work as they are, and an absent url still means retry in case that state ever exists.

Verification

  • Live, end to end, after the fix — the same call that previously threw SubstackAPIException: 400 Bad Request:
POST /subscriber_set              → 200
POST /subscriber_set/export       → 200
GET  /export/…  +0.2s             → 400  → substack.export.pending
GET  /export/…  +1.3s             → 400  → substack.export.pending
GET  /export/…  +6.3s             → 200  {url}
GET  /export/…/file               → 200  text/csv
export_subscribers.done  count: 6, waited_seconds: 6
  • 5 new tests, confirmed red before the fix, covering the pending 400, a non-pending 400 still aborting, and status/body on the error.
  • 753 pass / 0 fail on both supported runtimes (the engines floor, 22, and .nvmrc, 24).
  • Protocol surface unchanged: handshake ok, 27 tools.

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.md therefore gains the two rules that follow, next to "A new test that passes on the first run has proven nothing":

  1. A fixture representing an API state must cite where that state was observed; a transient or error-state fixture is corrected by nobody, unlike a happy-path one, so those are the ones to suspect.
  2. A time-dependent flow is verified by running the real handler, never by hand. Hand-issued requests land seconds apart and cannot enter a 3-second window; the code polls 200ms in and cannot avoid it. The technique was already in the file — the token check drove the real handlers and read the log — it just had not been applied here.

Out of scope, noted while verifying

group_membership does export (measured: 46 columns requested → 45 returned, only tag_ids missing). CLAUDE.md and 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

`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
marcomoauro merged commit e7dc7f9 into main Sep 3, 2026
2 checks passed
@marcomoauro
marcomoauro deleted the fix/export-subscribers-pending-poll branch September 3, 2026 13:30
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>
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.

export_subscribers always fails: subscriber_set rejects sort_direction

1 participant