Merge branch 'release_25.1' into dev

This commit is contained in:
Nicola Soranzo
2025-11-25 21:34:28 +00:00
12 changed files with 312 additions and 54 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@galaxyproject/galaxy-client",
"version": "24.0.0",
"version": "25.0.4",
"description": "Galaxy client application build system",
"keywords": [
"galaxy"
+14 -1
View File
@@ -1,5 +1,6 @@
import createClient from "openapi-fetch";
import { createRateLimiterMiddleware } from "@/api/client/rateLimiter";
import type { GalaxyApiPaths } from "@/api/schema";
import { getAppRoot } from "@/onload/loadConfig";
@@ -9,7 +10,19 @@ function getBaseUrl() {
}
function apiClientFactory() {
return createClient<GalaxyApiPaths>({ baseUrl: getBaseUrl() });
const client = createClient<GalaxyApiPaths>({ baseUrl: getBaseUrl() });
// TODO: Adjust based on server limits (maybe this goes in Galaxy config?)
client.use(
createRateLimiterMiddleware({
maxRequests: 100,
windowMs: 3000,
retryDelay: 1000,
maxRetries: 3,
}),
);
return client;
}
export type GalaxyApiClient = ReturnType<typeof apiClientFactory>;
+139
View File
@@ -0,0 +1,139 @@
import type { MessageException } from "@/api";
import { GalaxyApi } from "@/api/client";
import { useServerMock } from "@/api/client/__mocks__";
import { DEFAULT_CONFIG } from "./rateLimiter";
const { server, http } = useServerMock();
/** Spy to count number of times a 429 response is returned */
const mock429ResponseSpy = jest.fn();
describe("Rate Limiter Middleware", () => {
let consoleWarnSpy: jest.SpyInstance;
let consoleErrorSpy: jest.SpyInstance;
/** Helper to verify that there is a 429 response without retries */
function ensure429AndNoRetries(response: Response, error: MessageException | undefined) {
// Verify the request failed as expected
expect(response.status).toBe(429);
expect(error).toBeDefined();
expect(error?.err_code).toBe(429);
// Verify no retry behavior occurred by confirming console warns/errors were not called
expect(consoleWarnSpy).not.toHaveBeenCalled();
expect(consoleErrorSpy).not.toHaveBeenCalled();
// Verify the mock 429 response was called only once (no retries)
expect(mock429ResponseSpy).toHaveBeenCalledTimes(1);
}
beforeEach(() => {
consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation();
consoleErrorSpy = jest.spyOn(console, "error").mockImplementation();
});
afterEach(() => {
consoleWarnSpy.mockRestore();
consoleErrorSpy.mockRestore();
mock429ResponseSpy.mockReset();
});
it("should retry 429 GET responses", async () => {
// Set up a mock GET endpoint that always returns 429
server.use(
http.get("/api/histories/{history_id}", ({ response }) => {
mock429ResponseSpy();
return response("4XX").json({ err_code: 429, err_msg: "Too Many Requests" }, { status: 429 });
}),
);
const { error, response } = await GalaxyApi().GET("/api/histories/{history_id}", {
params: {
path: { history_id: "test" },
},
});
// Verify the request failed as expected
expect(response.status).toBe(429);
expect(error).toBeDefined();
expect(error?.err_code).toBe(429);
// Verify retry behavior occurred by confirming console warns/errors
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining(`Received 429 from server, waiting ${DEFAULT_CONFIG.retryDelay}ms before retry`),
);
for (let i = 1; i <= DEFAULT_CONFIG.maxRetries; i++) {
expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining(`Retry ${i} also received 429`));
}
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining(`Max retries reached for request to ${response.url}`),
);
// Verify the mock 429 response was called the first time and then for each retry
expect(mock429ResponseSpy).toHaveBeenCalledTimes(DEFAULT_CONFIG.maxRetries + 1);
});
it("should not retry 429 POST responses", async () => {
// Set up a mock POST endpoint that always returns 429
server.use(
http.post("/api/chat", ({ response }) => {
mock429ResponseSpy();
return response("4XX").json({ err_code: 429, err_msg: "Too Many Requests" }, { status: 429 });
}),
);
const { error, response } = await GalaxyApi().POST("/api/chat", {
params: {
query: { job_id: "test" },
},
body: {
query: "test message",
context: "test",
},
});
ensure429AndNoRetries(response, error);
});
it("should not retry 429 DELETE responses", async () => {
// Set up a mock DELETE endpoint that always returns 429
server.use(
http.delete("/api/datasets/{dataset_id}", ({ response }) => {
mock429ResponseSpy();
return response("4XX").json({ err_code: 429, err_msg: "Too Many Requests" }, { status: 429 });
}),
);
const { error, response } = await GalaxyApi().DELETE("/api/datasets/{dataset_id}", {
params: {
path: { dataset_id: "test_id" },
query: { purge: true },
},
});
ensure429AndNoRetries(response, error);
});
it("should not retry 429 PUT responses", async () => {
// Set up a mock PUT endpoint that always returns 429
server.use(
http.put("/api/datasets/{dataset_id}", ({ response }) => {
mock429ResponseSpy();
return response("4XX").json({ err_code: 429, err_msg: "Too Many Requests" }, { status: 429 });
}),
);
const { error, response } = await GalaxyApi().PUT("/api/datasets/{dataset_id}", {
params: {
path: { dataset_id: "test_id" },
},
body: {
deleted: false,
},
});
ensure429AndNoRetries(response, error);
});
});
+94
View File
@@ -0,0 +1,94 @@
import type { Middleware } from "openapi-fetch";
interface RateLimitConfig {
/** Maximum requests per window */
maxRequests?: number;
/** Time the requestwindow lasts in milliseconds */
windowMs?: number;
/** Delay between retries on 429 */
retryDelay?: number;
/** Maximum retry attempts */
maxRetries?: number;
}
export const DEFAULT_CONFIG: Required<RateLimitConfig> = {
maxRequests: 100,
windowMs: 60000,
retryDelay: 1000,
maxRetries: 3,
};
/**
* Rate limiting middleware to control the rate of API requests.
*
* Uses a timed window and only allows a maximum number of requests per that window.
*/
export function createRateLimiterMiddleware(config: RateLimitConfig = {}): Middleware {
const cfg = { ...DEFAULT_CONFIG, ...config };
let requestCount = 0;
let windowStart = Date.now();
/** Resets the request window if the time has elapsed */
function resetWindowIfNeeded() {
const now = Date.now();
if (now - windowStart >= cfg.windowMs) {
requestCount = 0;
windowStart = now;
}
}
/** Places a request in the rate limiter queue */
async function placeRequestInQueue(): Promise<void> {
resetWindowIfNeeded();
if (requestCount < cfg.maxRequests) {
requestCount++;
return;
}
// Rate limit exceeded, wait for next window
const waitTime = cfg.windowMs - (Date.now() - windowStart);
await new Promise((resolve) => setTimeout(resolve, waitTime));
// After waiting, try again (this will reset the window)
return placeRequestInQueue();
}
const middleware: Middleware = {
async onRequest({ request }) {
await placeRequestInQueue();
return request;
},
async onResponse({ response: res, request }) {
// Handle 429 Too Many Requests from server
if (res.status === 429 && request.method === "GET") {
const retryAfter = res.headers.get("Retry-After");
const delay = retryAfter ? parseInt(retryAfter) * 1000 : cfg.retryDelay;
console.warn(`Received 429 from server, waiting ${delay}ms before retry`);
let retries = 0;
while (retries < cfg.maxRetries) {
retries++;
await new Promise((resolve) => setTimeout(resolve, delay));
// A tricky thing here is that we will bypass the middleware chain on retry
const retryResponse = await fetch(request);
if (retryResponse.status !== 429) {
return retryResponse;
}
console.warn(`Retry ${retries} also received 429, retrying...`);
}
console.error(`Max retries reached for request to ${request.url}`);
}
return res;
},
};
return middleware;
}
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { faClock } from "@fortawesome/free-regular-svg-icons";
import { faHdd, faSitemap } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { storeToRefs } from "pinia";
@@ -123,7 +124,9 @@ function getInvocationBadges(invocation: WorkflowInvocation) {
:title-icon="{ icon: faSitemap }"
:title-n-lines="2"
title-size="text"
:update-time="invocation.update_time"
:update-time="invocation.create_time"
update-time-title="Invoked"
:update-time-icon="faClock"
@title-click="workflowName(invocation.workflow_id)"
@click="() => cardClicked(invocation)">
<template v-slot:description>
@@ -93,7 +93,7 @@ async function mountWorkflowAnnotation(version: "run_form" | "invocation", ownsW
propsData: {
workflowId: ownsWorkflow ? SAMPLE_WORKFLOW.id : OTHER_USER_WORKFLOW_ID,
historyId: TEST_HISTORY_ID,
invocationUpdateTime: version === "invocation" ? INVOCATION_TIME : undefined,
invocationCreateTime: version === "invocation" ? INVOCATION_TIME : undefined,
showDetails: version === "run_form",
},
localVue,
@@ -165,7 +165,7 @@ describe("WorkflowAnnotation renders", () => {
const { wrapper } = await mountWorkflowAnnotation("invocation");
const timeInfo = wrapper.find(SELECTORS.TIME_INFO);
expect(timeInfo.text()).toContain("updated");
expect(timeInfo.text()).toContain("invoked");
expect(timeInfo.find(SELECTORS.DATE).attributes("title")).toBe(INVOCATION_TIME);
});
});
@@ -17,14 +17,14 @@ import WorkflowIndicators from "@/components/Workflow/List/WorkflowIndicators.vu
interface Props {
workflowId: string;
invocationUpdateTime?: string;
invocationCreateTime?: string;
historyId: string;
showDetails?: boolean;
hideHr?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
invocationUpdateTime: undefined,
invocationCreateTime: undefined,
});
const { workflow, owned } = useWorkflowInstance(props.workflowId);
@@ -34,7 +34,7 @@ const description = computed(() => {
});
const timeElapsed = computed(() => {
return props.invocationUpdateTime || workflow.value?.update_time;
return props.invocationCreateTime || workflow.value?.update_time;
});
const workflowTags = computed(() => {
@@ -49,11 +49,11 @@ const workflowTags = computed(() => {
<i v-if="timeElapsed" data-description="workflow annotation time info">
<FontAwesomeIcon :icon="faClock" class="mr-1" />
<span v-localize>
{{ props.invocationUpdateTime ? "updated" : "edited" }}
{{ props.invocationCreateTime ? "invoked" : "edited" }}
</span>
<UtcDate :date="timeElapsed" mode="elapsed" data-description="workflow annotation date" />
</i>
<span v-if="invocationUpdateTime" class="d-flex flex-gapx-1 align-items-center">
<span v-if="invocationCreateTime" class="d-flex flex-gapx-1 align-items-center">
<FontAwesomeIcon :icon="faHdd" />History:
<span class="history-link-wrapper">
@@ -304,7 +304,7 @@ async function onCancel() {
<WorkflowAnnotation
v-if="props.isFullPage"
:workflow-id="invocation.workflow_id"
:invocation-update-time="invocation.update_time"
:invocation-create-time="invocation.create_time"
:history-id="invocation.history_id">
<template v-slot:middle-content>
<div class="progress-bars mx-1">
+1 -1
View File
@@ -44,7 +44,7 @@ hyphyvision:
version: 0.0.3
igv:
package: "@galaxyproject/igv"
version: 0.0.21
version: 0.0.23
jupyterlite:
package: "@galaxyproject/jupyterlite"
version: 0.0.17
+35 -31
View File
@@ -108,13 +108,6 @@ def get_anaconda_url(container, anaconda_channel="bioconda"):
return f"https://anaconda.org/{anaconda_channel}/{name[0]}/{name[1]}/download/linux-64/{'-'.join(name)}.tar.bz2"
def prepend_anaconda_url(url):
"""
Take a partial url and prepend 'https://anaconda.org'
"""
return f"https://anaconda.org{url}"
def get_test_from_anaconda(url: str) -> Optional[Dict[str, Any]]:
"""
Given the URL of an anaconda tarball, return tests
@@ -132,20 +125,26 @@ def get_test_from_anaconda(url: str) -> Optional[Dict[str, Any]]:
return None
def find_anaconda_versions(name, anaconda_channel="bioconda"):
def find_anaconda_download_url(
name: str, version: str, build: Optional[str] = None, anaconda_channel: str = "bioconda"
) -> Optional[str]:
"""
Find a list of available anaconda versions for a given container name
Find the anaconda download url for a given package.
"""
r = requests.get(
f"https://anaconda.org/{anaconda_channel}/{name}/files",
f"https://api.anaconda.org/package/{anaconda_channel}/{name}/files",
timeout=MULLED_SOCKET_TIMEOUT,
)
r.raise_for_status()
urls = []
for line in r.text.splitlines():
if "download/linux" in line:
urls.append(line.split('"')[1])
return urls
package_files = r.json()
for package_file in reversed(package_files):
if (
package_file["version"] == version
and (build is None or package_file["attrs"]["build"] == build)
and package_file["attrs"]["subdir"] in ["linux-64", "noarch"]
):
return f"https:{package_file['download_url']}"
return None
def open_recipe_file(file, recipes_path=None, github_repo="bioconda/bioconda-recipes"):
@@ -211,50 +210,55 @@ def deep_test_search(
"""
Look in bioconda-recipes repo as well as anaconda for the tests, checking in multiple possible locations. If no test is found for the specified version, search if other package versions have a test available.
"""
name = split_container_name(container)
name_tuple = split_container_name(container)
assert len(name_tuple) in (2, 3)
name = name_tuple[0]
version = name_tuple[1]
build = name_tuple[2] if len(name_tuple) == 3 else None
for f in [
(
get_commands_from_yaml,
open_recipe_file,
(f"recipes/{name[0]}/{name[1]}/meta.yaml", recipes_path, github_repo),
(f"recipes/{name}/{version}/meta.yaml", recipes_path, github_repo),
container,
),
(
get_run_test,
open_recipe_file,
(f"recipes/{name[0]}/{name[1]}/run_test.sh", recipes_path, github_repo),
(f"recipes/{name}/{version}/run_test.sh", recipes_path, github_repo),
container,
),
(
get_commands_from_yaml,
open_recipe_file,
(f"recipes/{name[0]}/meta.yaml", recipes_path, github_repo),
(f"recipes/{name}/meta.yaml", recipes_path, github_repo),
container,
),
(get_run_test, open_recipe_file, (f"recipes/{name[0]}/run_test.sh", recipes_path, github_repo), container),
(get_run_test, open_recipe_file, (f"recipes/{name}/run_test.sh", recipes_path, github_repo), container),
(get_test_from_anaconda, get_anaconda_url, (container, anaconda_channel), container),
]:
result = try_a_func(*f)
if result:
return result
versions = get_alternative_versions(f"recipes/{name[0]}", "meta.yaml", recipes_path, github_repo)
for version in versions:
result = try_a_func(get_commands_from_yaml, open_recipe_file, (version, recipes_path, github_repo), container)
alt_versions = get_alternative_versions(f"recipes/{name}", "meta.yaml", recipes_path, github_repo)
for alt_version in alt_versions:
result = try_a_func(
get_commands_from_yaml, open_recipe_file, (alt_version, recipes_path, github_repo), container
)
if result:
return result
versions = get_alternative_versions(f"recipes/{name[0]}", "run_test.sh", recipes_path, github_repo)
for version in versions:
result = try_a_func(get_run_test, open_recipe_file, (version, recipes_path, github_repo), container)
alt_versions = get_alternative_versions(f"recipes/{name}", "run_test.sh", recipes_path, github_repo)
for alt_version in alt_versions:
result = try_a_func(get_run_test, open_recipe_file, (alt_version, recipes_path, github_repo), container)
if result:
return result
versions = find_anaconda_versions(name[0], anaconda_channel)
for version in versions:
result = try_a_func(get_test_from_anaconda, prepend_anaconda_url, (version,), container)
if result:
return result
url = find_anaconda_download_url(name, version, build=build, anaconda_channel=anaconda_channel)
result = try_a_func(get_test_from_anaconda, lambda x: x, (url,), container)
if result:
return result
# if everything fails
return {"container": container}
+1 -1
View File
@@ -17,7 +17,7 @@
},
"homepage": "https://github.com/galaxyproject/galaxy#readme",
"dependencies": {
"@galaxyproject/galaxy-client": "^24.0.0",
"@galaxyproject/galaxy-client": "^25.0.4",
"cpy-cli": "^5.0.0"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
+15 -10
View File
@@ -2,7 +2,7 @@ from unittest import SkipTest
from galaxy.tool_util.deps.mulled.get_tests import (
deep_test_search,
find_anaconda_versions,
find_anaconda_download_url,
get_alternative_versions,
get_anaconda_url,
get_commands_from_yaml,
@@ -11,7 +11,6 @@ from galaxy.tool_util.deps.mulled.get_tests import (
hashed_test_search,
main_test_search,
open_recipe_file,
prepend_anaconda_url,
)
from galaxy.util import smart_str
from ..util import external_dependency_management
@@ -49,11 +48,6 @@ def test_get_anaconda_url():
assert url == "https://anaconda.org/bioconda/samtools/1.7/download/linux-64/samtools-1.7-1.tar.bz2"
def test_prepend_anaconda_url():
url = prepend_anaconda_url("/bioconda/samtools/0.1.12/download/linux-64/samtools-0.1.12-2.tar.bz2")
assert url == "https://anaconda.org/bioconda/samtools/0.1.12/download/linux-64/samtools-0.1.12-2.tar.bz2"
@external_dependency_management
def test_get_test_from_anaconda():
# test old fashion tar.bz2 package
@@ -81,9 +75,20 @@ def test_get_test_from_anaconda():
@external_dependency_management
def test_find_anaconda_versions():
versions = find_anaconda_versions("2pg_cartesian")
assert "/bioconda/2pg_cartesian/1.0.1/download/linux-64/2pg_cartesian-1.0.1-0.tar.bz2" in versions
def test_find_anaconda_download_url():
download_url = find_anaconda_download_url("2pg_cartesian", "1.0.0")
assert download_url is None
download_url = find_anaconda_download_url("2pg_cartesian", "1.0.1")
assert download_url is not None
assert download_url.startswith(
"https://api.anaconda.org/download/bioconda/2pg_cartesian/1.0.1/linux-64/2pg_cartesian-1.0.1-"
)
download_url = find_anaconda_download_url("2pg_cartesian", "1.0.1", "0")
assert download_url is not None
assert (
download_url
== "https://api.anaconda.org/download/bioconda/2pg_cartesian/1.0.1/linux-64/2pg_cartesian-1.0.1-0.tar.bz2"
)
@external_dependency_management