taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit 4c651dcda0749046d495916227f39a5214bb527f
parent 7535b45024251cfc23489f48b9b2bc7ff8a1cf34
Author: Florian Dold <florian@dold.me>
Date:   Wed, 15 Jul 2026 16:39:35 +0200

support progress token for getExchangeTos

Diffstat:
Mpackages/taler-util/src/http-client/exchange-client.ts | 24++++++++++++++++++++++++
Mpackages/taler-util/src/types-taler-wallet.ts | 2++
Mpackages/taler-wallet-core/src/common.ts | 5+++++
Mpackages/taler-wallet-core/src/exchanges.ts | 172+++++++++++++++++++++++++++++++------------------------------------------------
Mpackages/taler-wallet-core/src/wallet.ts | 23++++++++++++++++++-----
5 files changed, 116 insertions(+), 110 deletions(-)

diff --git a/packages/taler-util/src/http-client/exchange-client.ts b/packages/taler-util/src/http-client/exchange-client.ts @@ -266,6 +266,30 @@ export class TalerExchangeHttpClient { } } + async getTermsText( + args: { + acceptLanguage?: string; + acceptFormats?: string[]; + } = {}, + ): Promise<OperationOk<{ text: string }>> { + const acceptFormats = args.acceptFormats ?? ["text/plain"]; + const headers = { + Accept: acceptFormats.join(", "), + "Accept-Language": args.acceptLanguage, + }; + const resp = await this.fetch("terms", { + headers, + }); + switch (resp.status) { + case HttpStatusCode.Ok: + return opFixedSuccess(resp, { + text: await resp.text(), + }); + default: + return opUnknownHttpFailure(resp); + } + } + // WALLET TO WALLET /** diff --git a/packages/taler-util/src/types-taler-wallet.ts b/packages/taler-util/src/types-taler-wallet.ts @@ -2334,6 +2334,7 @@ export interface GetExchangeTosRequest { exchangeBaseUrl: string; acceptedFormat?: string[]; acceptLanguage?: string; + progressToken?: string; } export const codecForGetExchangeTosRequest = (): Codec<GetExchangeTosRequest> => @@ -2341,6 +2342,7 @@ export const codecForGetExchangeTosRequest = (): Codec<GetExchangeTosRequest> => .property("exchangeBaseUrl", codecForCanonBaseUrl()) .property("acceptedFormat", codecOptional(codecForList(codecForString()))) .property("acceptLanguage", codecOptional(codecForString())) + .property("progressToken", codecOptional(codecForString())) .build("GetExchangeTosRequest"); export interface AcceptManualWithdrawalRequest { diff --git a/packages/taler-wallet-core/src/common.ts b/packages/taler-wallet-core/src/common.ts @@ -567,6 +567,11 @@ const defaultRetryPolicy: RetryPolicy = { maxTimeout: Duration.fromSpec({ minutes: 2 }), }; +export function getRetryDuration(count: number): Duration { + const p = defaultRetryPolicy; + return Duration.multiply(p.backoffDelta, Math.pow(p.backoffBase, count)); +} + function updateTimeout( r: DbRetryInfo, p: RetryPolicy = defaultRetryPolicy, diff --git a/packages/taler-wallet-core/src/exchanges.ts b/packages/taler-wallet-core/src/exchanges.ts @@ -73,6 +73,7 @@ import { TalerError, TalerErrorCode, TalerErrorDetail, + TalerExchangeHttpClient, TalerPreciseTimestamp, TalerProtocolDuration, TalerProtocolTimestamp, @@ -114,7 +115,6 @@ import { HttpRequestLibrary, readResponseJsonOrThrow, readSuccessResponseJsonOrThrow, - readSuccessResponseTextOrThrow, readTalerErrorResponse, throwUnexpectedRequestError, } from "@gnu-taler/taler-util/http"; @@ -133,6 +133,7 @@ import { getExchangeState, getExchangeTosStatusFromRecord, getExchangeUpdateStatusFromRecord, + getRetryDuration, } from "./common.js"; import { DenomFamilyParams, @@ -208,64 +209,6 @@ interface ExchangeTosDownloadResult { tosAvailableLanguages: string[]; } -async function downloadExchangeWithTermsOfService( - wex: WalletExecutionContext, - exchangeBaseUrl: string, - http: HttpRequestLibrary, - timeout: Duration, - acceptFormat: string, - acceptLanguage: string | undefined, -): Promise<ExchangeTosDownloadResult> { - logger.trace(`downloading exchange tos (type ${acceptFormat})`); - const reqUrl = new URL("terms", exchangeBaseUrl); - const headers: { - Accept: string; - "Accept-Language"?: string; - } = { - Accept: acceptFormat, - }; - - if (acceptLanguage) { - headers["Accept-Language"] = acceptLanguage; - } - - const resp = await http.fetch(reqUrl.href, { - headers, - timeout, - cancellationToken: wex.cancellationToken, - }); - - if ( - (resp.status >= 500 && resp.status <= 599) || - (resp.status >= 400 && resp.status >= 499) - ) { - const innerError = await readTalerErrorResponse(resp); - throw TalerError.fromDetail(TalerErrorCode.WALLET_EXCHANGE_UNAVAILABLE, { - exchangeBaseUrl, - innerError, - }); - } - - const tosText = await readSuccessResponseTextOrThrow(resp); - const tosEtag = resp.headers.get("taler-terms-version") || "unknown"; - const tosContentLanguage = resp.headers.get("content-language") || undefined; - const tosContentType = resp.headers.get("content-type") || "text/plain"; - const availLangStr = resp.headers.get("avail-languages") || ""; - // Work around exchange bug that reports the same language multiple times. - const availLangSet = new Set<string>( - availLangStr.split(",").map((x) => x.trim()), - ); - const tosAvailableLanguages = [...availLangSet]; - - return { - tosText, - tosEtag, - tosContentType, - tosContentLanguage, - tosAvailableLanguages, - }; -} - /** * Get exchange details from the database. */ @@ -1008,44 +951,6 @@ async function downloadTosMeta( }; } -async function downloadTosFromAcceptedFormat( - wex: WalletExecutionContext, - baseUrl: string, - timeout: Duration, - acceptedFormat?: string[], - acceptLanguage?: string, -): Promise<ExchangeTosDownloadResult> { - let tosFound: ExchangeTosDownloadResult | undefined; - // Remove this when exchange supports multiple content-type in accept header - if (acceptedFormat) - for (const format of acceptedFormat) { - const resp = await downloadExchangeWithTermsOfService( - wex, - baseUrl, - wex.http, - timeout, - format, - acceptLanguage, - ); - if (resp.tosContentType === format) { - tosFound = resp; - break; - } - } - if (tosFound !== undefined) { - return tosFound; - } - // If none of the specified format was found try text/plain - return await downloadExchangeWithTermsOfService( - wex, - baseUrl, - wex.http, - timeout, - "text/plain", - acceptLanguage, - ); -} - /** * Check if an exchange entry should be considered * to be outdated. @@ -2811,8 +2716,11 @@ export async function getExchangeTos( exchangeBaseUrl: string, acceptedFormat?: string[], acceptLanguage?: string, + progressContext?: ProgressContext, ): Promise<GetExchangeTosResult> { - const exch = await fetchFreshExchange(wex, exchangeBaseUrl); + const exch = await fetchFreshExchange(wex, exchangeBaseUrl, { + progressContext, + }); switch (exch.tosStatus) { case ExchangeTosStatus.MissingTos: @@ -2827,13 +2735,67 @@ export async function getExchangeTos( }; } - const tosDownload = await downloadTosFromAcceptedFormat( - wex, - exchangeBaseUrl, - getExchangeRequestTimeout(), - acceptedFormat, - acceptLanguage, - ); + const exchangeClient = new TalerExchangeHttpClient(exchangeBaseUrl, { + cancelationToken: wex.cancellationToken, + httpClient: wex.http, + }); + + let tosDownload: ExchangeTosDownloadResult | undefined; + + // FIXME: pull out retry logic into helper. + + let retryCount = 0; + + while (1) { + if (progressContext?.cts?.token.isCancelled || progressContext?.finished) { + // FIXME: Error code! + throw Error("cancelled"); + } + try { + const tosRes = await exchangeClient.getTermsText({ + acceptFormats: acceptedFormat, + acceptLanguage: acceptLanguage, + }); + + switch (tosRes.type) { + case "ok": + tosDownload = { + tosAvailableLanguages: + tosRes.response.headers.get("avail-languages")?.split(",") ?? [], + tosContentLanguage: + tosRes.response.headers.get("content-language") ?? "en", + tosContentType: + tosRes.response.headers.get("content-type") ?? "text/plain", + tosEtag: tosRes.response.headers.get("etag") ?? "", + tosText: tosRes.body.text, + }; + break; + default: + continue; + } + } catch (e) { + if (!progressContext) { + throw e; + } + const delay = getRetryDuration(retryCount); + wex.ws.notify({ + type: NotificationType.RequestProgress, + operation: progressContext.operation, + progressToken: progressContext.progressToken, + error: TalerError.fromException(e).errorDetail, + nextRetryDelay: Duration.toTalerProtocolDuration(delay), + retryCounter: retryCount, + }); + retryCount++; + await wex.ws.timerGroup.resolveAfter(delay); + } + + if (tosDownload != null) { + break; + } + } + + checkLogicInvariant(!!tosDownload); await wex.runLegacyWalletDbTx(async (tx) => { const updateExchangeEntry = await tx.exchanges.get(exchangeBaseUrl); diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts @@ -331,7 +331,11 @@ import { } from "./denominations.js"; import { UnverifiedDenomError } from "./denomSelection.js"; import { checkDepositGroup, createDepositGroup } from "./deposits.js"; -import { DevExperimentHttpLib, DevExperimentState, applyDevExperiment } from "./dev-experiments.js"; +import { + DevExperimentHttpLib, + DevExperimentState, + applyDevExperiment, +} from "./dev-experiments.js"; import { handleGetDonau, handleGetDonauStatements, @@ -1272,11 +1276,20 @@ async function handleGetExchangeTos( wex: WalletExecutionContext, req: GetExchangeTosRequest, ): Promise<GetExchangeTosResult> { - return getExchangeTos( + return await withMaybeProgressContext( wex, - req.exchangeBaseUrl, - req.acceptedFormat, - req.acceptLanguage, + "getExchangeTos", + req.progressToken, + undefined, + async (pc) => { + return getExchangeTos( + wex, + req.exchangeBaseUrl, + req.acceptedFormat, + req.acceptLanguage, + pc, + ); + }, ); }