From 2aeef3010ed3e2f002400219f07d1e02533644be Mon Sep 17 00:00:00 2001 From: dev Date: Wed, 29 Apr 2026 21:35:14 +0200 Subject: [PATCH] Extract http request attempt --- NEXT_STEPS.md | 1 + src/arts/http/engine-http.ts | 76 ++++++-------------------- src/arts/http/request-attempt.ts | 93 ++++++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+), 61 deletions(-) create mode 100644 src/arts/http/request-attempt.ts diff --git a/NEXT_STEPS.md b/NEXT_STEPS.md index 702e6da..ca38f9a 100644 --- a/NEXT_STEPS.md +++ b/NEXT_STEPS.md @@ -14,6 +14,7 @@ Estado al cierre: - `svrs/auth/adapters/memory-store.ts` queda como composition root de 39 lineas; la logica se reparte en stores internos de credentials, flows, linked accounts, sessions/devices, refresh y state/snapshot. - `arts/perm/client.ts` delega transporte HTTP/JSON en `client-http.ts`; el cliente queda centrado en cache, snapshot y fallback. - `arts/perm/client.ts` delega TTL, cache positiva, backoff de fallos e invalidacion por prefijo en `client-cache.ts`. + - `arts/http/engine-http.ts` delega ejecucion de cada intento, hooks pre-request y diagnosticos request/network en `request-attempt.ts`. - `arts/timr/engine-timers.ts` delega `TimerHandle` cancel/reschedule en `timer-handle.ts`. - `arts/stor/engine-storage.ts` delega IDs de adapter, bus keys y suscripciones cross-tab en `adapter-registry.ts`. - `arts/stor/engine-storage.ts` delega el registro de defaults conflictivos en `defaults-registry.ts`. diff --git a/src/arts/http/engine-http.ts b/src/arts/http/engine-http.ts index b876a38..901eddc 100644 --- a/src/arts/http/engine-http.ts +++ b/src/arts/http/engine-http.ts @@ -1,8 +1,7 @@ import type { StandardSchemaV1 } from '$libs/standard-schema'; -import { mergeHeaders, serializeBody } from './body.ts'; +import { serializeBody } from './body.ts'; import { - HTTP_HEADER_CONTENT_TYPE, HTTP_METHOD_DELETE, HTTP_METHOD_GET, HTTP_METHOD_HEAD, @@ -20,11 +19,9 @@ import { resolveHttpCallRetry, type ResolvedHttpDefaults } from './engine-options.ts'; -import { - runBeforeRequest, - runBeforeRetry -} from './hooks.ts'; +import { runBeforeRetry } from './hooks.ts'; import { computeRetryDelay, delayWithSignal, shouldRetryRequest } from './retry.ts'; +import { runHttpAttempt } from './request-attempt.ts'; import { validateHttpRequestBody } from './request-validation.ts'; import { resolveHttpFinalResult } from './response-resolution.ts'; import { appendSearch, normalizeSearch, resolveUrl } from './search.ts'; @@ -38,7 +35,6 @@ import type { EngineHttp, EngineHttpOptions, HookContext, - HookRequest, HttpBodyRequestInit, HttpGetInit, HttpInit, @@ -138,56 +134,22 @@ async function execute( while (true) { const attemptSignal = attemptTimeoutSignal(init?.timeout ?? defaults.timeout); const signal = composeSignals([userSignal, totalSignal, attemptSignal]); - - // Headers refresh per attempt — `defaults.headers` may be a hook - // (auth refresh pattern) and `beforeRetry` may have updated the - // closure-captured token. - const headers = await mergeHeaders(defaults.headers, init?.headers); - if (contentType !== undefined && !headers.has(HTTP_HEADER_CONTENT_TYPE)) { - headers.set(HTTP_HEADER_CONTENT_TYPE, contentType); - } - - // Fresh request bag per attempt — hooks mutate this; mutations within - // an attempt persist to fetch, but each retry starts from defaults. - const request: HookRequest = { + const attemptResult = await runHttpAttempt({ + defaults, + hooks, + fetchImpl, method, - headers, + fullUrl, + init, + attempt, body: serializedBody, + contentType, signal - }; - const ctx: HookContext = { url: fullUrl, request, attempt, init: init ?? {} }; + }); + const ctx = attemptResult.ctx; lastCtx = ctx; - - // beforeRequest hooks may short-circuit by returning a Response. - const earlyResponse = await runBeforeRequest(hooks.beforeRequest, ctx); - - try { - emitHttpDiagnostic(diagnostics, HTTP_DIAGNOSTIC_EVENTS.REQUEST, { - method, - url: ctx.url, - attempt - }); - - response = - earlyResponse instanceof Response - ? earlyResponse - : await fetchImpl(ctx.url, { - method: ctx.request.method, - headers: ctx.request.headers, - body: ctx.request.body, - signal: ctx.request.signal - }); - lastError = undefined; - } catch (err) { - response = undefined; - lastError = classifyFetchError(err, signal); - emitHttpDiagnostic(diagnostics, HTTP_DIAGNOSTIC_EVENTS.NETWORK_ERROR, { - method, - url: ctx.url, - attempt, - error: lastError instanceof Error ? lastError : new Error(String(lastError)) - }); - } + response = attemptResult.response; + lastError = attemptResult.error; // Decide whether to retry. if (retry !== null) { @@ -245,11 +207,3 @@ async function execute( lastError }); } - -function classifyFetchError(err: unknown, signal: AbortSignal): unknown { - if (signal.aborted) { - const classified = classifyAbort(signal); - if (classified !== null) return classified; - } - return err; -} diff --git a/src/arts/http/request-attempt.ts b/src/arts/http/request-attempt.ts new file mode 100644 index 0000000..404be76 --- /dev/null +++ b/src/arts/http/request-attempt.ts @@ -0,0 +1,93 @@ +import { mergeHeaders } from './body.ts'; +import { + HTTP_DIAGNOSTIC_EVENTS, + HTTP_HEADER_CONTENT_TYPE +} from './consts.ts'; +import { emitHttpDiagnostic } from './diagnostics.ts'; +import { runBeforeRequest } from './hooks.ts'; +import { classifyAbort } from './timeout.ts'; +import type { ResolvedHttpDefaults } from './engine-options.ts'; +import type { + HookContext, + HookRequest, + HttpHooks, + HttpInit, + HttpMethod +} from './types.ts'; + +export interface HttpAttemptInput { + readonly defaults: ResolvedHttpDefaults; + readonly hooks: Required; + readonly fetchImpl: typeof fetch; + readonly method: HttpMethod; + readonly fullUrl: string; + readonly init: HttpInit | undefined; + readonly attempt: number; + readonly body: BodyInit | undefined; + readonly contentType: string | undefined; + readonly signal: AbortSignal; +} + +export interface HttpAttemptResult { + readonly ctx: HookContext; + readonly response: Response | undefined; + readonly error: unknown; +} + +export async function runHttpAttempt(input: HttpAttemptInput): Promise { + const headers = await mergeHeaders(input.defaults.headers, input.init?.headers); + if (input.contentType !== undefined && !headers.has(HTTP_HEADER_CONTENT_TYPE)) { + headers.set(HTTP_HEADER_CONTENT_TYPE, input.contentType); + } + + const request: HookRequest = { + method: input.method, + headers, + body: input.body, + signal: input.signal + }; + const ctx: HookContext = { + url: input.fullUrl, + request, + attempt: input.attempt, + init: input.init ?? {} + }; + + const earlyResponse = await runBeforeRequest(input.hooks.beforeRequest, ctx); + + try { + emitHttpDiagnostic(input.defaults.diagnostics, HTTP_DIAGNOSTIC_EVENTS.REQUEST, { + method: input.method, + url: ctx.url, + attempt: input.attempt + }); + + const response = + earlyResponse instanceof Response + ? earlyResponse + : await input.fetchImpl(ctx.url, { + method: ctx.request.method, + headers: ctx.request.headers, + body: ctx.request.body, + signal: ctx.request.signal + }); + return { ctx, response, error: undefined }; + } catch (err) { + const error = classifyFetchError(err, input.signal); + emitHttpDiagnostic(input.defaults.diagnostics, HTTP_DIAGNOSTIC_EVENTS.NETWORK_ERROR, { + method: input.method, + url: ctx.url, + attempt: input.attempt, + error: error instanceof Error ? error : new Error(String(error)) + }); + return { ctx, response: undefined, error }; + } +} + +function classifyFetchError(err: unknown, signal: AbortSignal): unknown { + if (signal.aborted) { + const classified = classifyAbort(signal); + if (classified !== null) return classified; + } + return err; +}