Skip to content

fix(client): SSE iterator's TReturn was the endpoint's HTTP error union#3919

Draft
kitlangton wants to merge 1 commit into
hey-api:mainfrom
kitlangton:fix/sse-iterator-treturn-miswiring
Draft

fix(client): SSE iterator's TReturn was the endpoint's HTTP error union#3919
kitlangton wants to merge 1 commit into
hey-api:mainfrom
kitlangton:fix/sse-iterator-treturn-miswiring

Conversation

@kitlangton
Copy link
Copy Markdown

Problem

SseFn in every generated client (fetch, axios, ky, next, ofetch, angular) returns Promise<ServerSentEventsResult<TData, TError>>. But:

// packages/openapi-ts/src/plugins/@hey-api/client-core/bundle/serverSentEvents.ts
export type ServerSentEventsResult<TData = unknown, TReturn = void, TNext = unknown> = {
  stream: AsyncGenerator<
    TData extends Record<string, unknown> ? TData[keyof TData] : TData,
    TReturn,
    TNext
  >;
};

That second slot is the underlying AsyncGenerator's return-value type — not an error channel. HTTP errors are thrown from the initial promise or surfaced via onSseError; an async generator's .return() value has nothing to do with them.

The SSE implementation itself agrees: createSseClient signs as ServerSentEventsResult<TData> (no TError), and its inner async function* has no explicit return statement, so the real TReturn is void. Only the public SseFn wrapper type passes the wrong generic.

This has been latent since d43ef3f3b ("feat(client): add support for server-sent events", Aug 2025). It's harmless while consumers' SSE error responses are effectively unknown — but it breaks every .return() call and every mock async generator the moment any SSE endpoint declares a concrete error type.

Repro

import type { ServerSentEventsResult } from "@hey-api/client-fetch";

// Stand-in for any SDK whose OpenAPI spec gives an SSE endpoint a 4xx response
type EndpointError = { 400: { kind: "BadRequest"; message: string } };

declare function callSse(): Promise<ServerSentEventsResult<unknown, EndpointError>>;

async function consume() {
  const { stream } = await callSse();

  // ❌ TS2345 — closing an iterator (spec allows undefined) suddenly fails to typecheck
  await stream.return(undefined);
  //                  ~~~~~~~~~
  //   Argument of type 'undefined' is not assignable to parameter of type
  //   'EndpointError | PromiseLike<EndpointError>'.

  // ❌ TS2322 — mock generators that complete normally (TReturn = void) no longer fit
  const mock: typeof stream = (async function* () {
    yield { hello: "world" };
  })();
  //   ~~~~ 'AsyncGenerator<…, void>' is not assignable to
  //        'AsyncGenerator<…, EndpointError>'
}

We hit this in the wild after a downstream codegen added concrete error responses to an SSE endpoint — see opencode/pull/28503 for the consumer-side patch we shipped to unblock CI while this PR is in flight.

Fix

Drop TError from the Promise<ServerSentEventsResult<...>> return type in all six affected client bundles so TReturn defaults to void. The TError generic parameter is preserved (renamed to _TError so noUnusedParameters lets it through, plus an eslint-disable for the lint rule) so the SDK template — which emits <TResponse, TError, ThrowOnError> uniformly for every method — keeps working without codegen changes.

Nuxt's SseFn uses a different shape (its TError flows into RequestResult, not into ServerSentEventsResult's TReturn) and was unaffected.

The 196 snapshot churn is mechanical — same edit as the source files. Regenerating instead of hand-editing produces identical output (verified locally).

Test plan

  • pnpm typecheck — 37/37 packages
  • pnpm vitest run across @test/openapi-ts, @test/openapi-ts-zod-v4, @test/openapi-ts-valibot-v1, @test/openapi-ts-tanstack-query-v5, @test/openapi-ts-sdks717/717 tests pass
  • pnpm lint:fix — clean
  • Pre-commit hook (lint-staged) passes

Filed as draft to invite discussion on the _TError + eslint-disable shape — happy to instead update the SDK generator template (packages/openapi-ts/src/plugins/@hey-api/sdk/shared/operation.ts:458-481) to stop emitting symbolErrorType for SSE calls, which would let us drop the TError generic from SseFn entirely. That's a slightly larger but cleaner change; let me know which direction you'd prefer.

@bolt-new-by-stackblitz
Copy link
Copy Markdown

Review PR in StackBlitz Codeflow Run & review this pull request in StackBlitz Codeflow.

@vercel
Copy link
Copy Markdown

vercel Bot commented May 20, 2026

@kitlangton is attempting to deploy a commit to the Hey API Team on Vercel.

A member of the Team first needs to authorize it.

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 20, 2026

🦋 Changeset detected

Latest commit: 58962a0

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@hey-api/openapi-ts Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Copy link
Copy Markdown
Contributor

@pullfrog pullfrog Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ Solid bug analysis and a minimal fix. One design question worth answering before merge.

Reviewed changesSseFn's second generic was being plumbed into ServerSentEventsResult's TReturn slot (the async-iterator return-value type) instead of an error channel. The diff corrects all six affected client bundles and regenerates the matching snapshots.

  • Drop TError from Promise<ServerSentEventsResult<...>> — for client-fetch / client-axios / client-ky / client-next / client-ofetch / client-angular, so TReturn defaults to void (matching what createSseClient actually returns at packages/openapi-ts/src/plugins/@hey-api/client-core/bundle/serverSentEvents.ts:72,92).
  • Preserve TError arity as _TError — kept the unused generic slot (with an eslint-disable-next-line @typescript-eslint/no-unused-vars comment, consistent with 8 other call sites in the repo) so the SDK template at packages/openapi-ts/src/plugins/@hey-api/sdk/shared/operation.ts:475-477 keeps emitting <TResponse, TError, ThrowOnError> uniformly without codegen changes.
  • Regenerate 196 types.gen.ts snapshots — the same 3-line edit propagated mechanically.
  • Add .changeset/sse-iterator-return-type.md — single @hey-api/openapi-ts patch entry.
  • Nuxt is correctly left alone — Nuxt's SseFn returns Promise<ServerSentEventsResult<RequestResult<TComposable, ResT, TError>>> (Nuxt threads TError through RequestResult, filling slot 1 / TData, not TReturn), so the same bug doesn't exist there.

ℹ️ _TError + eslint-disable vs. dropping the generic from SDK emission

The PR body already flags this and it's the only real decision the diff leaves open. The current shape keeps SseFn's generic arity at 3 with an underscore-prefixed unused slot purely so sdk/shared/operation.ts:471-477 can keep emitting <TResponse, TError, ThrowOnError> for every method. The alternative — making the SDK template skip symbolErrorType when hasServerSentEvents is true and dropping TError from SseFn entirely — is a slightly larger change but removes the dead generic + lint suppression and stops other consumers of SseFn from being able to pass an error type that silently does nothing.

Two reasonable paths; the maintainer's call which one to ship.

Technical details
# `_TError` + `eslint-disable` vs. dropping the generic from SDK emission

## Affected sites
- `packages/openapi-ts/src/plugins/@hey-api/client-{fetch,axios,ky,next,ofetch,angular}/bundle/types.ts``SseFn` keeps a `_TError` slot purely for call-site arity compatibility.
- `packages/openapi-ts/src/plugins/@hey-api/sdk/shared/operation.ts:471-477` — uniformly emits `.generic(symbolErrorType ?? 'unknown').generic('ThrowOnError')` for every method, including SSE.

## Required outcome
- Pick one shape and stick with it across all six clients.

## Suggested approach
Two options, pick one:

1. **Ship as-is.** Smaller blast radius. The unused `_TError` slot is harmless and the underscore-prefix convention is idiomatic. Cost: any future consumer typing `client.sse.post<Foo, MyError>(…)` gets no compile-time signal that `MyError` is ignored.

2. **Drop `TError` from `SseFn` (3 → 2 generics) and update the SDK template.** In `sdk/shared/operation.ts:461-483`, branch the `.generic(...)` chain on `hasServerSentEvents` so SSE calls emit `<TResponse, ThrowOnError>` (no error slot). Removes the dead generic from the public surface entirely. Cost: touches codegen + regenerates the same 196 snapshots a second time.

## Open questions for the human
- Which direction do you want? Path 1 is what's currently in the PR. Path 2 is cleaner long-term but needs sign-off because it changes the emitted SDK call-site shape.

ℹ️ Changeset entry is far longer than the repo convention

Existing changesets in .changeset/ are one-line **scope**: short description entries (e.g. cuddly-papayas-laugh.md, late-shirts-repeat.md). The new file packs the entire PR description into one paragraph. Not blocking — but worth trimming to a single line and letting the PR body / commit message carry the detail, for consistency with the surrounding changesets.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread .changeset/sse-iterator-return-type.md Outdated
"@hey-api/openapi-ts": patch
---

**client**: fix `SseFn` returning an `AsyncGenerator` whose `TReturn` is the endpoint's HTTP error union. The error generic was being threaded into `ServerSentEventsResult`'s second generic slot, which is the iterator's return-value type — not an error channel. Harmless while error responses are `unknown`, but breaks every `.return()` call and every mock async generator the moment an SSE endpoint declares a concrete error type. Fix in all six affected client bundles (fetch/axios/ky/next/ofetch/angular) by dropping `TError` from `Promise<ServerSentEventsResult<TData, TError>>` so `TReturn` defaults to `void`, matching what `createSseClient` actually returns. The `TError` generic parameter is preserved (with an `eslint-disable` for the now-unused declaration) so call-site arity in the SDK template, which emits `<TResponse, TError, ThrowOnError>` uniformly for every method, keeps working without codegen changes. The Nuxt client's `SseFn` uses a different shape and was unaffected.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changeset entries in this repo are one-line **scope**: short description — see cuddly-papayas-laugh.md / late-shirts-repeat.md. Worth trimming to e.g. **client**: fix SSE iterator's TReturn being the endpoint's HTTP error union and letting the PR body / commit message carry the detail.

>(
options: Omit<RequestOptions<never, ThrowOnError>, 'method'>,
) => Promise<ServerSentEventsResult<TData, TError>>;
) => Promise<ServerSentEventsResult<TData>>;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified the analysis: ServerSentEventsResult<TData, TReturn = void, TNext = unknown> at client-core/bundle/serverSentEvents.ts:72, and createSseClient is signed as ServerSentEventsResult<TData> at :92 with no explicit return — so TReturn really is void and threading TError into slot 2 was wrong. The fix here is correct.

>(
options: Omit<RequestOptions<never, ThrowOnError>, 'method'>,
) => Promise<ServerSentEventsResult<TData, TError>>;
) => Promise<ServerSentEventsResult<TData>>;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same root cause as the other five clients — fix is correct and the _TError slot keeps the SDK template at sdk/shared/operation.ts:475-477 working without codegen changes.

@codecov
Copy link
Copy Markdown

codecov Bot commented May 20, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 37.70%. Comparing base (0c865e1) to head (58962a0).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files
@@           Coverage Diff           @@
##             main    #3919   +/-   ##
=======================================
  Coverage   37.70%   37.70%           
=======================================
  Files         582      582           
  Lines       20844    20844           
  Branches     6063     6070    +7     
=======================================
  Hits         7860     7860           
  Misses      10570    10570           
  Partials     2414     2414           
Flag Coverage Δ
unittests 37.70% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@mrlubos
Copy link
Copy Markdown
Member

mrlubos commented May 20, 2026

@kitlangton is this related to #3018 in any way? That's another long-standing issue, wonder if both can be fixed at once here

SseFn in every generated client (fetch / axios / ky / next / ofetch /
angular) returns Promise<ServerSentEventsResult<TData, TError>>, but
ServerSentEventsResult<TData, TReturn = void, TNext = unknown> — that
second slot is the underlying AsyncGenerator's return-value type, not
an error channel. HTTP errors are thrown from the initial promise or
surfaced via onSseError; an async generator's .return() value has
nothing to do with them.

createSseClient itself signs as ServerSentEventsResult<TData> (no
TError), and the inner async function* has no explicit return, so the
real TReturn is void. Only the public wrapper type is wrong.

Latent for ~9 months since d43ef3f (feat(client): add support for
server-sent events). Harmless while users' SSE error responses were
effectively unknown, but breaks every .return() call and every mock
async generator the moment any SSE endpoint declares a concrete error
response — they suddenly fail to satisfy an iterator-return contract
that should never have existed.

Drop TError from the Promise<ServerSentEventsResult<...>> return type
so TReturn defaults to void. The TError generic parameter is preserved
(renamed to _TError so noUnusedParameters lets it through, with an
eslint-disable for the corresponding lint rule) so the SDK template,
which emits <TResponse, TError, ThrowOnError> uniformly for every
method, keeps working without codegen changes.

Nuxt's SseFn uses a different shape and was unaffected.
@kitlangton kitlangton force-pushed the fix/sse-iterator-treturn-miswiring branch from 9093357 to 58962a0 Compare May 20, 2026 18:58
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 20, 2026

Open in StackBlitz

@hey-api/codegen-core

npm i https://pkg.pr.new/@hey-api/codegen-core@3919

@hey-api/json-schema-ref-parser

npm i https://pkg.pr.new/@hey-api/json-schema-ref-parser@3919

@hey-api/nuxt

npm i https://pkg.pr.new/@hey-api/nuxt@3919

@hey-api/openapi-ts

npm i https://pkg.pr.new/@hey-api/openapi-ts@3919

@hey-api/shared

npm i https://pkg.pr.new/@hey-api/shared@3919

@hey-api/spec-types

npm i https://pkg.pr.new/@hey-api/spec-types@3919

@hey-api/types

npm i https://pkg.pr.new/@hey-api/types@3919

@hey-api/vite-plugin

npm i https://pkg.pr.new/@hey-api/vite-plugin@3919

commit: 58962a0

@kitlangton
Copy link
Copy Markdown
Author

They're in the same area but they fix different things.

#3018 is a runtime change. It updates what the iterator yields on each tick, so that yield data becomes yield sseEvent and each yielded value includes id, event, and retry.

This PR is type-only and it changes a different slot. SseFn was routing the endpoint's HTTP error union into the TReturn position of ServerSentEventsResult<TData, TReturn>, which is the iterator's completion value. That position is not what the iterator yields, and it is not an error channel either.

The two PRs touch different files and different lines, so there is no overlap and they can merge in either order. I am happy to rebase on whichever one lands first.

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.

2 participants