fix(tauri): preserve HTTP timeout and response semantics

- Apply validated request timeouts without overriding the shared client default

- Preserve abort behavior and null-body response statuses

Co-authored-by: Joseph Cumines <joeycumines@gmail.com>
This commit is contained in:
Danila Poyarkov 2026-08-13 18:31:36 +03:00
parent c7b944d103
commit 6d81d53d1d
3 changed files with 208 additions and 9 deletions

View file

@ -61,17 +61,23 @@ pub async fn proxy_http_request(request: ProxyHttpRequest) -> Result<ProxyHttpRe
} else {
reqwest::redirect::Policy::none()
};
let mut client_builder = reqwest::Client::builder().redirect(redirect_policy);
if let Some(timeout_ms) = request.timeout_ms {
client_builder = client_builder.timeout(Duration::from_millis(timeout_ms));
}
let client = client_builder.build().map_err(|e| e.to_string())?;
let client = reqwest::Client::builder()
.redirect(redirect_policy)
.timeout(Duration::from_secs(30))
.build()
.map_err(|e| e.to_string())?;
let mut builder = client
.request(request_method(request.method)?, parsed)
.headers(request_headers(request.headers));
if let Some(body) = request.body {
builder = builder.body(body);
}
if let Some(timeout_ms) = request.timeout_ms {
if timeout_ms == 0 {
return Err("timeoutMs must be greater than 0".into());
}
builder = builder.timeout(Duration::from_millis(timeout_ms));
}
let mut response = builder.send().await.map_err(|e| e.to_string())?;
let status = response.status().as_u16();

View file

@ -1,9 +1,9 @@
interface ProxyHttpHeader {
export interface ProxyHttpHeader {
name: string
value: string
}
interface ProxyHttpRequest {
export interface ProxyHttpRequest {
url: string
method?: string
headers?: ProxyHttpHeader[]
@ -13,13 +13,20 @@ interface ProxyHttpRequest {
timeout_ms?: number
}
interface ProxyHttpResponse {
export interface ProxyHttpResponse {
status: number
headers: ProxyHttpHeader[]
body: number[]
url: string
}
const NULL_BODY_STATUS_CODES = new Set([204, 205, 304])
function responseBodyForStatus(response: ProxyHttpResponse): BodyInit | null {
if (NULL_BODY_STATUS_CODES.has(response.status)) return null
return Uint8Array.from(response.body)
}
function headersToProxyHeaders(headers: Headers): ProxyHttpHeader[] {
return [...headers.entries()].map(([name, value]) => ({ name, value }))
}
@ -60,6 +67,15 @@ export function withAbortSignal<T>(promise: Promise<T>, signal: AbortSignal): Pr
})
}
export interface TauriFetchOptions {
timeoutMs?: number
maxResponseBytes?: number
}
export function createTauriFetch(options: TauriFetchOptions = {}): typeof fetch {
return (input, init) => tauriFetch(input, init, options.maxResponseBytes, options.timeoutMs)
}
export async function tauriFetch(
input: RequestInfo | URL,
init?: RequestInit,
@ -69,6 +85,7 @@ export async function tauriFetch(
const request = new Request(input, init)
request.signal.throwIfAborted()
const { invoke } = await import('@tauri-apps/api/core')
request.signal.throwIfAborted()
const payload: ProxyHttpRequest = {
url: request.url,
method: request.method,
@ -83,7 +100,7 @@ export async function tauriFetch(
invoke<ProxyHttpResponse>('proxy_http_request', { request: payload }),
request.signal
)
const proxiedResponse = new Response(new Uint8Array(response.body), {
const proxiedResponse = new Response(responseBodyForStatus(response), {
status: response.status,
headers: response.headers.map(({ name, value }): [string, string] => [name, value])
})

View file

@ -0,0 +1,176 @@
import { afterEach, describe, expect, test } from 'bun:test'
import {
createTauriFetch,
tauriFetch,
type ProxyHttpRequest,
type ProxyHttpResponse
} from '@/app/tauri/http'
import { clearTauriMocks, mockTauriIPC } from '#tests/helpers/tauri/mocks'
type InvokeArgs = { request: ProxyHttpRequest }
function proxyBodyText(request: ProxyHttpRequest): string {
return new TextDecoder().decode(new Uint8Array(request.body ?? []))
}
function proxyHeaderValue(request: ProxyHttpRequest, name: string): string | null {
return request.headers?.find((header) => header.name.toLowerCase() === name)?.value ?? null
}
async function withBrowserStrictNullBodyResponse<T>(callback: () => Promise<T>): Promise<T> {
const originalResponse = globalThis.Response
const StrictResponse = class extends originalResponse {
constructor(body?: BodyInit | null, init?: ResponseInit) {
const status = init?.status
if ((status === 204 || status === 205 || status === 304) && body != null) {
throw new TypeError('Response with null body status cannot have body')
}
super(body, init)
}
} as typeof Response
globalThis.Response = StrictResponse
try {
return await callback()
} finally {
globalThis.Response = originalResponse
}
}
afterEach(async () => {
await clearTauriMocks()
})
describe('tauriFetch', () => {
test('passes request timeout metadata to the desktop HTTP command', async () => {
let captured: InvokeArgs | null = null
await mockTauriIPC((command, args) => {
expect(command).toBe('proxy_http_request')
captured = args as InvokeArgs
return {
status: 201,
headers: [{ name: 'x-open-pencil', value: 'ok' }],
body: [...new TextEncoder().encode('OK')]
}
})
const response = await createTauriFetch({ timeoutMs: 15_000 })('https://example.test/check', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: '{"ok":true}'
})
if (!captured) throw new Error('Expected proxy_http_request to be invoked')
expect(response.status).toBe(201)
expect(response.headers.get('x-open-pencil')).toBe('ok')
expect(await response.text()).toBe('OK')
expect(captured.request.url).toBe('https://example.test/check')
expect(captured.request.method).toBe('POST')
expect(captured.request.timeout_ms).toBe(15_000)
expect(captured.request.body).toEqual([...new TextEncoder().encode('{"ok":true}')])
})
test('forwards bodies from Request inputs', async () => {
let captured: InvokeArgs | null = null
await mockTauriIPC((command, args) => {
expect(command).toBe('proxy_http_request')
captured = args as InvokeArgs
return { status: 204, headers: [], body: [] }
})
const request = new Request('https://example.test/from-request', {
method: 'POST',
body: 'from-request'
})
const response = await tauriFetch(request)
if (!captured) throw new Error('Expected proxy_http_request to be invoked')
expect(response.status).toBe(204)
expect(captured.request.method).toBe('POST')
expect(proxyBodyText(captured.request)).toBe('from-request')
})
test('constructs null bodies for browser null-body response statuses', async () => {
await mockTauriIPC((command) => {
expect(command).toBe('proxy_http_request')
return { status: 204, headers: [{ name: 'x-no-content', value: '1' }], body: [] }
})
await withBrowserStrictNullBodyResponse(async () => {
const response = await tauriFetch('https://example.test/no-content')
expect(response.status).toBe(204)
expect(response.headers.get('x-no-content')).toBe('1')
expect(await response.text()).toBe('')
})
})
test('forwards FormData bytes with the Request-generated content boundary', async () => {
let captured: InvokeArgs | null = null
await mockTauriIPC((command, args) => {
expect(command).toBe('proxy_http_request')
captured = args as InvokeArgs
return { status: 204, headers: [], body: [] }
})
const formData = new FormData()
formData.append('family', 'Inter')
await tauriFetch('https://example.test/upload', { method: 'POST', body: formData })
if (!captured) throw new Error('Expected proxy_http_request to be invoked')
const contentType = proxyHeaderValue(captured.request, 'content-type')
const boundary = contentType?.match(/boundary=(.+)$/)?.[1]
if (!boundary) throw new Error(`Expected multipart boundary in content-type: ${contentType}`)
const body = proxyBodyText(captured.request)
expect(body).toContain(`--${boundary}`)
expect(body).toContain('name="family"')
expect(body).toContain('Inter')
})
test('rejects already-aborted requests before invoking the desktop command', async () => {
let calls = 0
await mockTauriIPC(() => {
calls += 1
return { status: 204, headers: [], body: [] }
})
const controller = new AbortController()
controller.abort()
await expect(
tauriFetch('https://example.test/slow', { signal: controller.signal })
).rejects.toHaveProperty('name', 'AbortError')
expect(calls).toBe(0)
})
test('rejects when an in-flight desktop command is aborted', async () => {
let calls = 0
let markStarted: (() => void) | null = null
let resolvePendingResponse: ((value: ProxyHttpResponse) => void) | null = null
const started = new Promise<void>((resolve) => {
markStarted = resolve
})
await mockTauriIPC(() => {
calls += 1
const resolve = markStarted
if (!resolve) throw new Error('Expected start resolver to be installed')
resolve()
return new Promise<ProxyHttpResponse>((pendingResolve) => {
resolvePendingResponse = pendingResolve
})
})
const controller = new AbortController()
const request = tauriFetch('https://example.test/slow', { signal: controller.signal })
await started
controller.abort()
await expect(request).rejects.toHaveProperty('name', 'AbortError')
expect(calls).toBe(1)
expect(resolvePendingResponse).toBeTypeOf('function')
})
})