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
8 changes: 7 additions & 1 deletion src/bee.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2602,6 +2602,12 @@ export class Bee {
requestOptions = prepareBeeRequestOptions(requestOptions)
}

return requestOptions ? Objects.deepMerge2(this.requestOptions, requestOptions) : this.requestOptions
const merged = requestOptions ? Objects.deepMerge2(this.requestOptions, requestOptions) : this.requestOptions

if (requestOptions?.signal) {
merged.signal = requestOptions.signal
}

return merged
}
}
1 change: 1 addition & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export type BeeRequestOptions = {
httpAgent?: unknown
httpsAgent?: unknown
endlesslyRetry?: boolean
signal?: AbortSignal
}

/**
Expand Down
1 change: 1 addition & 0 deletions src/utils/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@ export class BeeResponseError extends BeeError {
super(message)
}
}

32 changes: 31 additions & 1 deletion src/utils/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const MAX_FAILED_ATTEMPTS = 100_000
const DELAY_FAST = 200
const DELAY_SLOW = 1000
const DELAY_THRESHOLD = Dates.minutes(1) / DELAY_FAST
const ABORT_ERROR_MESSAGE = 'Request aborted'

export const DEFAULT_HTTP_CONFIG: AxiosRequestConfig = {
headers: {
Expand All @@ -20,6 +21,24 @@ export const DEFAULT_HTTP_CONFIG: AxiosRequestConfig = {
maxContentLength: Infinity,
}

function throwIfAborted(
signal: AbortSignal | undefined,
config: AxiosRequestConfig,
responseData?: unknown,
responseStatus?: number,
): void {
if (signal?.aborted) {
throw new BeeResponseError(
config.method || 'get',
config.url || '<unknown>',
ABORT_ERROR_MESSAGE,
responseData,
responseStatus,
'ERR_CANCELED',
)
}
}

/**
* Main function to make HTTP requests.
* @param options User defined settings
Expand All @@ -28,6 +47,11 @@ export const DEFAULT_HTTP_CONFIG: AxiosRequestConfig = {
export async function http<T>(options: BeeRequestOptions, config: AxiosRequestConfig): Promise<AxiosResponse<T>> {
const requestConfig: AxiosRequestConfig = Objects.deepMerge3(DEFAULT_HTTP_CONFIG, config, options)

if (options.signal) {
requestConfig.signal = options.signal
throwIfAborted(options.signal, config)
}

if (requestConfig.data && typeof Buffer !== 'undefined' && Buffer.isBuffer(requestConfig.data)) {
requestConfig.data = requestConfig.data.buffer.slice(
requestConfig.data.byteOffset,
Expand All @@ -48,6 +72,8 @@ export async function http<T>(options: BeeRequestOptions, config: AxiosRequestCo

let failedAttempts = 0
while (failedAttempts < MAX_FAILED_ATTEMPTS) {
throwIfAborted(options.signal, config)

try {
debug(
`${requestConfig.method || 'get'} ${Strings.joinUrl([
Expand All @@ -62,6 +88,10 @@ export async function http<T>(options: BeeRequestOptions, config: AxiosRequestCo
return response as AxiosResponse<T>
} catch (e: unknown) {
if (e instanceof AxiosError) {
if (e.code === 'ERR_CANCELED') {
throwIfAborted({ aborted: true } as AbortSignal, config, e.response?.data, e.response?.status)
}

if (e.code === 'ECONNABORTED' && options.endlesslyRetry) {
failedAttempts++
await System.sleepMillis(failedAttempts < DELAY_THRESHOLD ? DELAY_FAST : DELAY_SLOW)
Expand All @@ -72,7 +102,7 @@ export async function http<T>(options: BeeRequestOptions, config: AxiosRequestCo
e.message,
e.response?.data,
e.response?.status,
e.code,
e.response?.statusText,
)
}
} else {
Expand Down
1 change: 1 addition & 0 deletions src/utils/type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export function prepareBeeRequestOptions(value: unknown): BeeRequestOptions {
httpAgent: object.httpAgent,
httpsAgent: object.httpsAgent,
endlesslyRetry: Types.asOptional(x => Types.asBoolean(x, { name: 'endlesslyRetry' }), object.endlesslyRetry),
signal: object.signal as AbortSignal | undefined,
}
}

Expand Down
38 changes: 38 additions & 0 deletions test/integration/abort.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { BeeResponseError } from '../../src'
import { batch, makeBee } from '../utils'

const bee = makeBee()

test('abort upload should reject with ERR_CANCELED', async () => {
const controller = new AbortController()
const largeData = 'x'.repeat(1024 * 1024) // 1MB to ensure request takes time

const uploadPromise = bee.uploadData(batch(), largeData, {}, { signal: controller.signal })

controller.abort()

await expect(uploadPromise).rejects.toThrow(BeeResponseError)
await expect(uploadPromise).rejects.toMatchObject({ statusText: 'ERR_CANCELED' })
})

test('AbortController signal works with uploadFile', async () => {
const controller = new AbortController()
const file = new File(['x'.repeat(1024 * 1024)], 'large.bin')

const uploadPromise = bee.uploadFile(batch(), file, 'large.bin', {}, { signal: controller.signal })

controller.abort()

await expect(uploadPromise).rejects.toThrow(BeeResponseError)
await expect(uploadPromise).rejects.toMatchObject({ statusText: 'ERR_CANCELED' })
})

test('non-aborted upload completes successfully', async () => {
const controller = new AbortController()
const data = 'Hello, Swarm!'

const result = await bee.uploadData(batch(), data, {}, { signal: controller.signal })

expect(result.reference).toBeTruthy()
expect(result.reference.length).toBeGreaterThan(0)
})
36 changes: 36 additions & 0 deletions test/unit/abort-controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { prepareBeeRequestOptions } from '../../src/utils/type'

test('prepareBeeRequestOptions should preserve signal', () => {
const controller = new AbortController()
const options = prepareBeeRequestOptions({
signal: controller.signal,
timeout: 5000,
})

expect(options.signal).toBe(controller.signal)
expect(options.signal?.aborted).toBe(false)
expect(options.timeout).toBe(5000)
})

test('prepareBeeRequestOptions should preserve aborted signal', () => {
const controller = new AbortController()
controller.abort()

const options = prepareBeeRequestOptions({
signal: controller.signal,
})

expect(options.signal).toBe(controller.signal)
expect(options.signal?.aborted).toBe(true)
})

test('prepareBeeRequestOptions should work without signal', () => {
const options = prepareBeeRequestOptions({
timeout: 3000,
endlesslyRetry: true,
})

expect(options.signal).toBeUndefined()
expect(options.timeout).toBe(3000)
expect(options.endlesslyRetry).toBe(true)
})