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
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,19 @@ async function clawFetch<T>(
message = body.error
}
} catch {}

// Provide helpful context for rate limiting
if (res.status === 429) {
const retryAfter = res.headers.get('Retry-After')
const trimmedRetryAfter = retryAfter?.trim()
const retryAfterMessage = trimmedRetryAfter
? /^\d+$/.test(trimmedRetryAfter)
? `${Number(trimmedRetryAfter)}s`
: trimmedRetryAfter
: undefined
message = `Rate limited (429)${retryAfterMessage ? `: retry after ${retryAfterMessage}` : ''}. ${message}`
}

throw new Error(message)
}
return res.json() as Promise<T>
Expand Down
48 changes: 42 additions & 6 deletions packages/browseros-agent/apps/server/src/lib/browseros-fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,22 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* Custom fetch for BrowserOS gateway requests.
* Adds X-BrowserOS-ID header for credit tracking,
* handles CREDITS_EXHAUSTED (429), and extracts OpenRouter-style error details.
* Features:
* - X-BrowserOS-ID header for credit tracking
* - Exponential backoff with jitter for rate-limited requests
* - Retry-After header respect (RFC 7231)
* - CREDITS_EXHAUSTED (429) detection (marked as non-retryable)
* - OpenRouter-style error detail extraction
*/

import { APICallError } from '@ai-sdk/provider'
import { HTTP_RETRY } from '@browseros/shared/constants/timeouts'
import { logger } from './logger'
import {
createRetryableFetch,
type FetchLike,
isRetryableStatus,
} from './retry-handler'

function resolveUrl(url: RequestInfo | URL): string {
return typeof url === 'string' ? url : url.toString()
Expand Down Expand Up @@ -39,11 +49,25 @@ function buildErrorMessage(
}

export function createBrowserOSFetch(browserosId: string): typeof fetch {
return (async (url: RequestInfo | URL, options?: RequestInit) => {
// Create a fetch wrapper that adds BrowserOS ID header
const fetchWithHeader: FetchLike = async (
url: RequestInfo | URL,
options?: RequestInit,
): Promise<Response> => {
const headers = new Headers(options?.headers)
headers.set('X-BrowserOS-ID', browserosId)
return globalThis.fetch(url, { ...options, headers })
}

const response = await globalThis.fetch(url, { ...options, headers })
// Wrap with retry handler for automatic exponential backoff
const retryableFetch = createRetryableFetch(fetchWithHeader, {
maxRetries: HTTP_RETRY.MAX_RETRIES,
baseDelayMs: HTTP_RETRY.BASE_DELAY_MS,
jitterMaxMs: HTTP_RETRY.JITTER_MAX_MS,
})

return (async (url: RequestInfo | URL, options?: RequestInit) => {
const response = await retryableFetch(url, options)

const creditsRemaining = response.headers.get('X-Credits-Remaining')
if (creditsRemaining !== null) {
Expand All @@ -54,18 +78,28 @@ export function createBrowserOSFetch(browserosId: string): typeof fetch {
const statusCode = response.status
const responseBody = await response.text()
const error = parseErrorBody(responseBody)
const isCreditsExhausted = error?.code === 'CREDITS_EXHAUSTED'
const retryAfterHeader = response.headers.get('Retry-After')
const responseHeaders = retryAfterHeader
? { 'retry-after': retryAfterHeader }
: undefined

if (statusCode === 429 && error?.code === 'CREDITS_EXHAUSTED') {
// CREDITS_EXHAUSTED is fatal and should not be retried
if (statusCode === 429 && isCreditsExhausted) {
throw new APICallError({
message: error.message ?? 'Daily credits exhausted',
message: error?.message ?? 'Daily credits exhausted',
url: resolveUrl(url),
requestBodyValues: {},
statusCode,
responseBody,
responseHeaders,
isRetryable: false,
})
}

// For other errors, calculate whether they're retryable
const isRetryable = isRetryableStatus(statusCode, false)

throw new APICallError({
message: error
? buildErrorMessage(statusCode, response.statusText, error)
Expand All @@ -74,6 +108,8 @@ export function createBrowserOSFetch(browserosId: string): typeof fetch {
requestBodyValues: {},
statusCode,
responseBody,
responseHeaders,
isRetryable,
})
}

Expand Down
37 changes: 29 additions & 8 deletions packages/browseros-agent/apps/server/src/lib/openrouter-fetch.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,31 @@
import { APICallError } from '@ai-sdk/provider'
import { HTTP_RETRY } from '@browseros/shared/constants/timeouts'
import { createRetryableFetch, isRetryableStatus } from './retry-handler'

/**
* Creates a fetch function that extracts detailed error messages from OpenRouter-style APIs.
* Creates a fetch function for OpenRouter-compatible APIs.
* Features:
* - Exponential backoff with jitter for rate-limited requests (RFC 7231)
* - Retry-After header respect
* - OpenRouter-style error detail extraction and message enhancement
*
* OpenRouter (and BrowserOS which uses it internally) wraps provider errors in a generic
* "Provider returned error" message, with actual details hidden in metadata.raw.
* This fetch intercepts HTTP errors and extracts the real error message.
* The Vercel AI SDK's retry mechanism is also applied by this fetch wrapper,
* providing defense-in-depth retry handling. This wrapper explicitly classifies
* retryability for the APICallError it throws, and the SDK also respects the
* status code and Retry-After headers when it applies its own retry logic.
*
Comment thread
Sameer6305 marked this conversation as resolved.
* IMPORTANT: Throws APICallError (not plain Error) so the Vercel AI SDK's retry mechanism
* works correctly. The SDK's APICallError automatically calculates `isRetryable` from
* the statusCode (408, 409, 429, 500+ are retryable) - we don't override this default.
* Note: This is primarily for non-gateway APIs. For gateway requests, use createBrowserOSFetch.
*/
export function createOpenRouterCompatibleFetch(): typeof fetch {
// Wrap with retry handler for exponential backoff
const retryableFetch = createRetryableFetch(globalThis.fetch, {
maxRetries: HTTP_RETRY.MAX_RETRIES,
baseDelayMs: HTTP_RETRY.BASE_DELAY_MS,
jitterMaxMs: HTTP_RETRY.JITTER_MAX_MS,
})

return (async (url: RequestInfo | URL, options?: RequestInit) => {
const response = await globalThis.fetch(url, options)
const response = await retryableFetch(url, options)

if (!response.ok) {
const statusCode = response.status
Expand All @@ -36,12 +48,21 @@ export function createOpenRouterCompatibleFetch(): typeof fetch {
// Keep default error message if parsing fails
}

// Determine if retryable based on status code
const isRetryable = isRetryableStatus(statusCode, false)
const retryAfterHeader = response.headers.get('Retry-After')
Comment thread
Sameer6305 marked this conversation as resolved.
const responseHeaders = retryAfterHeader
? { 'retry-after': retryAfterHeader }
: undefined

throw new APICallError({
message: errorMessage,
url: typeof url === 'string' ? url : url.toString(),
requestBodyValues: {},
statusCode,
responseBody,
responseHeaders,
isRetryable,
})
}

Expand Down
Loading
Loading