Skip to content
Merged
4 changes: 2 additions & 2 deletions client/src/api/datasets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export async function loadDatasets(options: LoadDatasetsOptions): Promise<LoadDa
}

export async function fetchDatasetTextContentDetails(params: { id: string }): Promise<DatasetTextContentDetails> {
const { data, error } = await GalaxyApi().GET("/api/datasets/{dataset_id}/get_content_as_text", {
const { data, error, response } = await GalaxyApi().GET("/api/datasets/{dataset_id}/get_content_as_text", {
params: {
path: {
dataset_id: params.id,
Expand All @@ -64,7 +64,7 @@ export async function fetchDatasetTextContentDetails(params: { id: string }): Pr
});

if (error) {
rethrowSimple(error);
rethrowSimpleWithStatus(error, response);
}
return data;
}
Expand Down
24 changes: 21 additions & 3 deletions client/src/composables/keyedCache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,8 +223,8 @@ describe("useKeyedCache", () => {

const { getItemById, getItemLoadError } = useKeyedCache<ItemData>(fetchItem);

// First attempt + MAX_RETRIES - 1 retries (first call counts as attempt 1)
for (let i = 1; i <= 3; i++) {
// Initial fetch + MAX_RETRIES retries = 4 total calls
for (let i = 1; i <= 4; i++) {
getItemById.value(id);
await flushPromises();
expect(fetchItem).toHaveBeenCalledTimes(i);
Expand All @@ -234,7 +234,7 @@ describe("useKeyedCache", () => {
// Should stop after max retries exhausted
getItemById.value(id);
await flushPromises();
expect(fetchItem).toHaveBeenCalledTimes(3);
expect(fetchItem).toHaveBeenCalledTimes(4);
});

it("should not retry on permanent errors (403, 404)", async () => {
Expand Down Expand Up @@ -292,4 +292,22 @@ describe("useKeyedCache", () => {
await flushPromises();
expect(true).toBe(true);
});

it("should clear error on successful recovery after transient failure", async () => {
const id = "1";
const item = { id, name: "Item 1" };
fetchItem.mockRejectedValueOnce(new ApiError("service unavailable", 503));
fetchItem.mockResolvedValue(item);

const { getItemById, storedItems, getItemLoadError } = useKeyedCache<ItemData>(fetchItem);

getItemById.value(id);
await flushPromises();
expect(getItemLoadError.value(id)).toBeTruthy();

getItemById.value(id);
await flushPromises();
expect(storedItems.value[id]).toEqual(item);
expect(getItemLoadError.value(id)).toBeNull();
});
});
26 changes: 14 additions & 12 deletions client/src/composables/keyedCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@ import { computed, del, type Ref, ref, set, unref } from "vue";
import { LastQueue } from "@/utils/lastQueue";
import { ApiError } from "@/utils/simple-error";

const RETRYABLE_STATUSES = new Set([429, 500, 502, 503, 504]);
const MAX_RETRIES = 3;

function isRetryableError(error: Error): boolean {
if (error instanceof ApiError && error.status !== undefined) {
return RETRYABLE_STATUSES.has(error.status);
}
return false;
}

/**
* Parameters for fetching an item from the server.
*
Expand All @@ -30,16 +40,6 @@ type ShouldFetchHandler<T> = (item?: T) => boolean;
*/
const fetchIfAbsent = <T>(item?: T) => item === undefined;

const RETRYABLE_STATUSES = new Set([429, 500, 502, 503, 504]);
const MAX_RETRIES = 3;

function isRetryableError(error: Error): boolean {
if (error instanceof ApiError && error.status !== undefined) {
return RETRYABLE_STATUSES.has(error.status);
}
return false;
}

/**
* A composable that provides a simple key-value cache for items fetched from the server.
*
Expand All @@ -56,17 +56,18 @@ export function useKeyedCache<T>(
) {
const storedItems = ref<{ [key: string]: T }>({});
const loadingErrors = ref<{ [key: string]: Error }>({});
const retryCounts: { [key: string]: number } = {};

const loadingRequests = ref<{ [key: string]: Promise<T | undefined> }>({});

const retryCounts: { [key: string]: number } = {};

const fetchQueue = new LastQueue<FetchHandler<T>>();

const getItemById = computed(() => {
return (id: string) => {
const item = storedItems.value[id];
const existingError = loadingErrors.value[id];
const canRetry = existingError && isRetryableError(existingError) && (retryCounts[id] ?? 0) < MAX_RETRIES;
const canRetry = existingError && isRetryableError(existingError) && (retryCounts[id] ?? 0) <= MAX_RETRIES;
if (shouldFetch(item) && (!existingError || canRetry)) {
fetchItemById({ id: id });
}
Expand Down Expand Up @@ -105,6 +106,7 @@ export function useKeyedCache<T>(
const fetchItem = unref(fetchItemHandler);
const item = await fetchQueue.enqueue(fetchItem, { id: itemId }, itemId);
set(storedItems.value, itemId, item);
del(loadingErrors.value, itemId);
delete retryCounts[itemId];
return item;
} catch (error) {
Expand Down
6 changes: 3 additions & 3 deletions client/src/stores/collectionAttributesStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@ import { defineStore } from "pinia";

import { type DatasetCollectionAttributes, GalaxyApi } from "@/api";
import { type FetchParams, useKeyedCache } from "@/composables/keyedCache";
import { rethrowSimple } from "@/utils/simple-error";
import { rethrowSimpleWithStatus } from "@/utils/simple-error";

export const useCollectionAttributesStore = defineStore("collectionAttributesStore", () => {
async function fetchAttributes(params: FetchParams): Promise<DatasetCollectionAttributes> {
const { data, error } = await GalaxyApi().GET("/api/dataset_collections/{hdca_id}/attributes", {
const { data, error, response } = await GalaxyApi().GET("/api/dataset_collections/{hdca_id}/attributes", {
params: { path: { hdca_id: params.id } },
});
if (error) {
rethrowSimple(error);
rethrowSimpleWithStatus(error, response);
}
return data;
}
Expand Down
6 changes: 3 additions & 3 deletions client/src/stores/datasetExtraFilesStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@ import { defineStore } from "pinia";
import { GalaxyApi } from "@/api";
import type { DatasetExtraFiles } from "@/api/datasets";
import { type FetchParams, useKeyedCache } from "@/composables/keyedCache";
import { rethrowSimple } from "@/utils/simple-error";
import { rethrowSimpleWithStatus } from "@/utils/simple-error";

export const useDatasetExtraFilesStore = defineStore("datasetExtraFilesStore", () => {
async function fetchDatasetExtraFiles(params: FetchParams): Promise<DatasetExtraFiles> {
const { data, error } = await GalaxyApi().GET("/api/datasets/{dataset_id}/extra_files", {
const { data, error, response } = await GalaxyApi().GET("/api/datasets/{dataset_id}/extra_files", {
params: { path: { dataset_id: params.id } },
});
if (error) {
rethrowSimple(error);
rethrowSimpleWithStatus(error, response);
}
return data;
}
Expand Down
26 changes: 13 additions & 13 deletions client/src/stores/invocationStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,71 +10,71 @@ import type {
WorkflowInvocationRequest,
} from "@/api/invocations";
import { type FetchParams, useKeyedCache } from "@/composables/keyedCache";
import { rethrowSimple } from "@/utils/simple-error";
import { rethrowSimple, rethrowSimpleWithStatus } from "@/utils/simple-error";

export const useInvocationStore = defineStore("invocationStore", () => {
const scrollListScrollTop = ref(0);

async function fetchInvocationDetails(params: FetchParams): Promise<WorkflowInvocation> {
const { data, error } = await GalaxyApi().GET("/api/invocations/{invocation_id}", {
const { data, error, response } = await GalaxyApi().GET("/api/invocations/{invocation_id}", {
params: { path: { invocation_id: params.id } },
});
if (error) {
rethrowSimple(error);
rethrowSimpleWithStatus(error, response);
}
return data;
}

async function fetchInvocationJobsSummary(params: FetchParams): Promise<InvocationJobsSummary> {
const { data, error } = await GalaxyApi().GET("/api/invocations/{invocation_id}/jobs_summary", {
const { data, error, response } = await GalaxyApi().GET("/api/invocations/{invocation_id}/jobs_summary", {
params: { path: { invocation_id: params.id } },
});
if (error) {
rethrowSimple(error);
rethrowSimpleWithStatus(error, response);
}
return data;
}

async function fetchInvocationStepJobsSummary(params: FetchParams): Promise<StepJobSummary[]> {
const { data, error } = await GalaxyApi().GET("/api/invocations/{invocation_id}/step_jobs_summary", {
const { data, error, response } = await GalaxyApi().GET("/api/invocations/{invocation_id}/step_jobs_summary", {
params: { path: { invocation_id: params.id } },
});
if (error) {
rethrowSimple(error);
rethrowSimpleWithStatus(error, response);
}
return data;
}

async function fetchInvocationStep(params: FetchParams): Promise<InvocationStep> {
const { data, error } = await GalaxyApi().GET("/api/invocations/steps/{step_id}", {
const { data, error, response } = await GalaxyApi().GET("/api/invocations/steps/{step_id}", {
params: { path: { step_id: params.id } },
});
if (error) {
rethrowSimple(error);
rethrowSimpleWithStatus(error, response);
}
return data;
}

async function fetchInvocationRequest(params: FetchParams): Promise<WorkflowInvocationRequest> {
const { data, error } = await GalaxyApi().GET("/api/invocations/{invocation_id}/request", {
const { data, error, response } = await GalaxyApi().GET("/api/invocations/{invocation_id}/request", {
params: {
path: {
invocation_id: params.id,
},
},
});
if (error) {
rethrowSimple(error);
rethrowSimpleWithStatus(error, response);
}
return data;
}

async function fetchInvocationCount(params: FetchParams): Promise<number> {
const { data, error } = await GalaxyApi().GET("/api/workflows/{workflow_id}/counts", {
const { data, error, response } = await GalaxyApi().GET("/api/workflows/{workflow_id}/counts", {
params: { path: { workflow_id: params.id } },
});
if (error) {
rethrowSimple(error);
rethrowSimpleWithStatus(error, response);
}

let allCounts = 0;
Expand Down
6 changes: 3 additions & 3 deletions client/src/stores/jobDestinationParametersStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,17 @@ import { defineStore } from "pinia";
import { GalaxyApi } from "@/api";
import type { JobDestinationParams } from "@/api/jobs";
import { type FetchParams, useKeyedCache } from "@/composables/keyedCache";
import { rethrowSimple } from "@/utils/simple-error";
import { rethrowSimpleWithStatus } from "@/utils/simple-error";

export const useJobDestinationParametersStore = defineStore("jobDestinationParametersStore", () => {
async function fetchJobDestinationParams(params: FetchParams): Promise<JobDestinationParams> {
const { data, error } = await GalaxyApi().GET("/api/jobs/{job_id}/destination_params", {
const { data, error, response } = await GalaxyApi().GET("/api/jobs/{job_id}/destination_params", {
params: {
path: { job_id: params.id },
},
});
if (error) {
rethrowSimple(error);
rethrowSimpleWithStatus(error, response);
}
return data;
}
Expand Down
6 changes: 3 additions & 3 deletions client/src/stores/jobStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,18 @@ import { ref } from "vue";
import { GalaxyApi } from "@/api";
import { type ResponseVal, type ShowFullJobResponse, TERMINAL_STATES } from "@/api/jobs";
import { type FetchParams, useKeyedCache } from "@/composables/keyedCache";
import { rethrowSimple } from "@/utils/simple-error";
import { rethrowSimpleWithStatus } from "@/utils/simple-error";

export const useJobStore = defineStore("jobStore", () => {
const latestResponse = ref<ResponseVal | null>(null);

async function fetchJobById(params: FetchParams): Promise<ShowFullJobResponse> {
const { data, error } = await GalaxyApi().GET("/api/jobs/{job_id}", {
const { data, error, response } = await GalaxyApi().GET("/api/jobs/{job_id}", {
params: { path: { job_id: params.id } },
query: { full: true },
});
if (error) {
rethrowSimple(error);
rethrowSimpleWithStatus(error, response);
}
return data;
}
Expand Down
Loading