Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 36 additions & 9 deletions docs/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,16 @@
Every failure the SDK gives up on is typed, and which type tells you
what you know about the request:

| Thrown | Means |
| ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| `BrewApiError` | The server answered with a non-2xx status. `status`, `code` and the rest of the envelope say what it said. |
| `BrewTimeoutError` (a `BrewTransportError`) | No complete answer within `timeoutMs` — waiting for the headers or reading the body. **Outcome unknown.** |
| `BrewConnectionError` (a `BrewTransportError`) | The connection failed, or dropped while the body streamed. **Outcome unknown.** The original error is on `cause`. |
| `BrewParseError` | The server answered 2xx, but the body was not JSON. Report it with `requestId`. |
| your signal's `reason` | You cancelled — the `signal` you passed (request- or client-level) aborted. |

All four classes extend `Error`, so the two-branch pattern below —
| Thrown | Means |
| ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `BrewApiError` | The server answered with a non-2xx status. `status`, `code` and the rest of the envelope say what it said. |
| `BrewTimeoutError` (a `BrewTransportError`) | No complete answer within `timeoutMs` — waiting for the headers or reading the body. **Outcome unknown.** |
| `BrewConnectionError` (a `BrewTransportError`) | The connection failed, or dropped while the body streamed. **Outcome unknown.** The original error is on `cause`. |
| `BrewParseError` | The server answered 2xx, but the body was not JSON. Report it with `requestId`. |
| `BrewPaginationError` | A list endpoint repeated a cursor. The iterator stops with `code: 'PAGINATION_CURSOR_CYCLE'`, `cursor`, `pagesFetched`, and `itemsYielded`. Already-yielded items are incomplete; do not treat them as a complete list. |
| your signal's `reason` | You cancelled — the `signal` you passed (request- or client-level) aborted. |

All SDK error classes extend `Error`, so the two-branch pattern below —
`BrewApiError` for API errors, `Error` for everything else — covers
every case.

Expand Down Expand Up @@ -338,3 +339,29 @@ interrupted write without doing it twice, replay it with the same
idempotency key. A cancel rejects with your own `reason`, which carries
no key, so on a write you might cancel, pass your own
`RequestOptions.idempotencyKey` and keep it.

## Pagination protocol failures

`autoPaginate` rejects an identical or cyclic next cursor before fetching a
known page again. Empty pages with an advancing cursor continue normally;
`hasMore: false` or a missing cursor ends iteration. Its documented cancellation
behavior is unchanged: an abort between pages ends iteration, while an abort
from an in-flight request propagates that request's signal reason.

```ts
import { BrewPaginationError } from '@brew.new/sdk'

try {
for await (const contact of brew.contacts.searchAll()) {
// Consume each contact.
}
} catch (error) {
if (error instanceof BrewPaginationError) {
console.error(`Incomplete list after ${error.itemsYielded} items`)
}
throw error
}
```

The repeated cursor is diagnostic evidence, not a safe resume cursor. Report
the failure instead of automatically retrying the same cursor.
18 changes: 12 additions & 6 deletions openapi/public-api-v1.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2807,7 +2807,8 @@ components:
- form
- import
- app
description: "Who captured it: `api` (an API caller asserted it), `form` (a signup form), `import` (a bulk import), `app` (the in-app CSV wizard checkbox)."
- integration
description: "Who captured it: `api` (an API caller asserted it), `form` (a signup form), `import` (a bulk import), `app` (the in-app CSV wizard checkbox), `integration` (an integration sync such as Stripe created the contact; provenance only, not an opt-in)."
capturedAt:
type: string
format: date-time
Expand Down Expand Up @@ -12578,7 +12579,8 @@ components:
- form
- import
- app
description: "Who captured it: `api` (an API caller asserted it), `form` (a signup form), `import` (a bulk import), `app` (the in-app CSV wizard checkbox)."
- integration
description: "Who captured it: `api` (an API caller asserted it), `form` (a signup form), `import` (a bulk import), `app` (the in-app CSV wizard checkbox), `integration` (an integration sync such as Stripe created the contact; provenance only, not an opt-in)."
capturedAt:
type: string
format: date-time
Expand Down Expand Up @@ -12880,7 +12882,8 @@ components:
- form
- import
- app
description: "Who captured it: `api` (an API caller asserted it), `form` (a signup form), `import` (a bulk import), `app` (the in-app CSV wizard checkbox)."
- integration
description: "Who captured it: `api` (an API caller asserted it), `form` (a signup form), `import` (a bulk import), `app` (the in-app CSV wizard checkbox), `integration` (an integration sync such as Stripe created the contact; provenance only, not an opt-in)."
capturedAt:
type: string
format: date-time
Expand Down Expand Up @@ -13041,7 +13044,8 @@ components:
- form
- import
- app
description: "Who captured it: `api` (an API caller asserted it), `form` (a signup form), `import` (a bulk import), `app` (the in-app CSV wizard checkbox)."
- integration
description: "Who captured it: `api` (an API caller asserted it), `form` (a signup form), `import` (a bulk import), `app` (the in-app CSV wizard checkbox), `integration` (an integration sync such as Stripe created the contact; provenance only, not an opt-in)."
capturedAt:
type: string
format: date-time
Expand Down Expand Up @@ -13165,7 +13169,8 @@ components:
- form
- import
- app
description: "Who captured it: `api` (an API caller asserted it), `form` (a signup form), `import` (a bulk import), `app` (the in-app CSV wizard checkbox)."
- integration
description: "Who captured it: `api` (an API caller asserted it), `form` (a signup form), `import` (a bulk import), `app` (the in-app CSV wizard checkbox), `integration` (an integration sync such as Stripe created the contact; provenance only, not an opt-in)."
capturedAt:
type: string
format: date-time
Expand Down Expand Up @@ -13311,7 +13316,8 @@ components:
- form
- import
- app
description: "Who captured it: `api` (an API caller asserted it), `form` (a signup form), `import` (a bulk import), `app` (the in-app CSV wizard checkbox)."
- integration
description: "Who captured it: `api` (an API caller asserted it), `form` (a signup form), `import` (a bulk import), `app` (the in-app CSV wizard checkbox), `integration` (an integration sync such as Stripe created the contact; provenance only, not an opt-in)."
capturedAt:
type: string
format: date-time
Expand Down
45 changes: 44 additions & 1 deletion src/core/pagination.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,26 @@ export type Page<TItem> = {
readonly pagination: Pagination | undefined
}

/** The server returned a cursor already fetched by this iterator. */
export class BrewPaginationError extends Error {
readonly code = 'PAGINATION_CURSOR_CYCLE'
readonly cursor: string
readonly pagesFetched: number
readonly itemsYielded: number

constructor(input: {
readonly cursor: string
readonly pagesFetched: number
readonly itemsYielded: number
}) {
super('Pagination returned a previously fetched cursor.')
this.name = 'BrewPaginationError'
this.cursor = input.cursor
this.pagesFetched = input.pagesFetched
this.itemsYielded = input.itemsYielded
}
}

/**
* Generic async-iterator that walks every page of a cursor-paginated
* list, yielding one row at a time. Resources build a thin `listAll`
Expand Down Expand Up @@ -67,21 +87,44 @@ export async function* autoPaginate<TItem>(
* before its cursor is known, so the next fetch cannot start early.
*/
let cursor: string | null = null
const fetched = new Set<string>()
let pagesFetched = 0
let itemsYielded = 0
const isAborted = () => options?.signal?.aborted === true
while (true) {
if (options?.signal?.aborted === true) {
if (isAborted()) {
return
}

const page = await fetchPage(cursor)
pagesFetched += 1
if (cursor !== null) {
fetched.add(cursor)
}
for (const item of page.items) {
itemsYielded += 1
yield item
}

// A caller may abort while consuming the page. Preserve quiet termination
// between pages even if the server also returned a cyclic next cursor.
if (isAborted()) {
return
}

const next = page.pagination
if (!next || !next.hasMore || next.cursor === null) {
return
}

if (fetched.has(next.cursor)) {
throw new BrewPaginationError({
cursor: next.cursor,
pagesFetched,
itemsYielded,
})
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

cursor = next.cursor
}
/* eslint-enable no-await-in-loop */
Expand Down
24 changes: 12 additions & 12 deletions src/generated/openapi-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3554,10 +3554,10 @@ export interface components {
subscribed?: boolean;
consent?: {
/**
* @description Who captured it: `api` (an API caller asserted it), `form` (a signup form), `import` (a bulk import), `app` (the in-app CSV wizard checkbox).
* @description Who captured it: `api` (an API caller asserted it), `form` (a signup form), `import` (a bulk import), `app` (the in-app CSV wizard checkbox), `integration` (an integration sync such as Stripe created the contact; provenance only, not an opt-in).
* @enum {string}
*/
source: "api" | "form" | "import" | "app";
source: "api" | "form" | "import" | "app" | "integration";
/**
* Format: date-time
* @description When it was captured.
Expand Down Expand Up @@ -6939,10 +6939,10 @@ export interface components {
subscribed?: boolean;
consent?: {
/**
* @description Who captured it: `api` (an API caller asserted it), `form` (a signup form), `import` (a bulk import), `app` (the in-app CSV wizard checkbox).
* @description Who captured it: `api` (an API caller asserted it), `form` (a signup form), `import` (a bulk import), `app` (the in-app CSV wizard checkbox), `integration` (an integration sync such as Stripe created the contact; provenance only, not an opt-in).
* @enum {string}
*/
source: "api" | "form" | "import" | "app";
source: "api" | "form" | "import" | "app" | "integration";
/**
* Format: date-time
* @description When it was captured.
Expand Down Expand Up @@ -7083,10 +7083,10 @@ export interface components {
subscribed?: boolean;
consent?: {
/**
* @description Who captured it: `api` (an API caller asserted it), `form` (a signup form), `import` (a bulk import), `app` (the in-app CSV wizard checkbox).
* @description Who captured it: `api` (an API caller asserted it), `form` (a signup form), `import` (a bulk import), `app` (the in-app CSV wizard checkbox), `integration` (an integration sync such as Stripe created the contact; provenance only, not an opt-in).
* @enum {string}
*/
source: "api" | "form" | "import" | "app";
source: "api" | "form" | "import" | "app" | "integration";
/**
* Format: date-time
* @description When it was captured.
Expand Down Expand Up @@ -7164,10 +7164,10 @@ export interface components {
subscribed?: boolean;
consent?: {
/**
* @description Who captured it: `api` (an API caller asserted it), `form` (a signup form), `import` (a bulk import), `app` (the in-app CSV wizard checkbox).
* @description Who captured it: `api` (an API caller asserted it), `form` (a signup form), `import` (a bulk import), `app` (the in-app CSV wizard checkbox), `integration` (an integration sync such as Stripe created the contact; provenance only, not an opt-in).
* @enum {string}
*/
source: "api" | "form" | "import" | "app";
source: "api" | "form" | "import" | "app" | "integration";
/**
* Format: date-time
* @description When it was captured.
Expand Down Expand Up @@ -7224,10 +7224,10 @@ export interface components {
subscribed?: boolean;
consent?: {
/**
* @description Who captured it: `api` (an API caller asserted it), `form` (a signup form), `import` (a bulk import), `app` (the in-app CSV wizard checkbox).
* @description Who captured it: `api` (an API caller asserted it), `form` (a signup form), `import` (a bulk import), `app` (the in-app CSV wizard checkbox), `integration` (an integration sync such as Stripe created the contact; provenance only, not an opt-in).
* @enum {string}
*/
source: "api" | "form" | "import" | "app";
source: "api" | "form" | "import" | "app" | "integration";
/**
* Format: date-time
* @description When it was captured.
Expand Down Expand Up @@ -7290,10 +7290,10 @@ export interface components {
subscribed?: boolean;
consent?: {
/**
* @description Who captured it: `api` (an API caller asserted it), `form` (a signup form), `import` (a bulk import), `app` (the in-app CSV wizard checkbox).
* @description Who captured it: `api` (an API caller asserted it), `form` (a signup form), `import` (a bulk import), `app` (the in-app CSV wizard checkbox), `integration` (an integration sync such as Stripe created the contact; provenance only, not an opt-in).
* @enum {string}
*/
source: "api" | "form" | "import" | "app";
source: "api" | "form" | "import" | "app" | "integration";
/**
* Format: date-time
* @description When it was captured.
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export {
} from './core/errors'
export type { Pagination, PaginationInput } from './core/pagination'
// ---------- Pagination ----------
export { autoPaginate } from './core/pagination'
export { autoPaginate, BrewPaginationError } from './core/pagination'
// Recursive JSON payload value for `emails.send` / `automations.triggers.fire`
// (replaces the removed `TransactionalPayloadValue`).
export type { SendPayloadValue } from './generated/openapi-types'
Expand Down
5 changes: 2 additions & 3 deletions src/resources/contacts/search-all.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,8 @@ export type SearchAllContactsInput = Readonly<
* `search` are still yielded by the loop, but no further fetches are
* issued.
*
* This is the filtered counterpart to `brew.contacts.listAll`: filtering
* moved off the list query onto the search endpoint, so any iteration
* that needs filters/search/sort goes through here.
* Filtering lives on the search endpoint, so iteration that needs
* filters/search/sort goes through here.
*
* Example:
* ```ts
Expand Down
Loading
Loading