This is an automated email from the ASF dual-hosted git repository.
bbovenzi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new 1c23f876f23 UI: Set the API client base URL before the first startup
request (#72374)
1c23f876f23 is described below
commit 1c23f876f235c70ce60e2defc0d644eb65bf299b
Author: Ed Summers <[email protected]>
AuthorDate: Fri Sep 4 12:40:08 2026 -0400
UI: Set the API client base URL before the first startup request (#72374)
When Airflow is served under a path prefix, the UI sent its first API
request
to the origin root instead of the prefix.
`OpenAPI.BASE` was assigned as a module side effect in `queryClient.ts`,
which
imports `src/i18n/config`. That module requests the version at module scope
to
build a translation cache buster, and ES module evaluation runs a
dependency to
completion before the importing module's body, so the request always went
out
while `BASE` was still `""`. The generated client builds its URL as
`config.BASE + path`, so the request was sent to the origin root.
The consequences depend on what else is mounted at `/api/`. In our case
the request reaches a different service, which responded with a 401
which the UI reads as an expired session, which redirects to login,
which succeeds and reloads, producing an infinite login loop. The
request also defeats the feature it exists for: the lookup fails,
`resolveI18nVersion` falls back to `Date.now()`, and translations are
fetched with a timestamp instead of the version.
Move the base href resolution and the client configuration into
`src/basePath`,
a module with no application dependencies, and have `i18n/config` take its
base
path from there. The ordering guarantee then comes from a real data
dependency
rather than from the import graph happening to line up.
`utils/links.ts` still reads `<base href>` separately, but it does so at
call
time rather than module scope, so it is unaffected; consolidating it is left
out to keep this change to the bug.
Adds a regression test that fails on main with
`expected [ '/api/v2/version' ] to deeply equal []`.
Closes: #72344
---
airflow-core/newsfragments/72374.bugfix.rst | 1 +
airflow-core/src/airflow/ui/src/basePath.ts | 39 +++++++++++++
airflow-core/src/airflow/ui/src/i18n/config.ts | 8 +--
.../src/airflow/ui/src/queryClient.test.ts | 65 ++++++++++++++++++++++
airflow-core/src/airflow/ui/src/queryClient.ts | 18 ++----
5 files changed, 113 insertions(+), 18 deletions(-)
diff --git a/airflow-core/newsfragments/72374.bugfix.rst
b/airflow-core/newsfragments/72374.bugfix.rst
new file mode 100644
index 00000000000..cf444e64b95
--- /dev/null
+++ b/airflow-core/newsfragments/72374.bugfix.rst
@@ -0,0 +1 @@
+The UI now configures the generated API client's base URL from ``<base href>``
in a module with no application dependencies, so a request issued while the app
is still initializing -- the i18n version lookup that builds the translation
cache buster -- is resolved against the configured path prefix instead of being
sent to the origin root.
diff --git a/airflow-core/src/airflow/ui/src/basePath.ts
b/airflow-core/src/airflow/ui/src/basePath.ts
new file mode 100644
index 00000000000..4c020facc47
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/basePath.ts
@@ -0,0 +1,39 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { OpenAPI } from "openapi/requests/core/OpenAPI";
+
+// Airflow can be served under a path prefix (`[api] base_url`), which the
server renders
+// into `<base href>`.
+const baseHref = document.querySelector("head > base")?.getAttribute("href")
?? "";
+
+export const basePath = new URL(baseHref,
globalThis.location.origin).pathname.replace(/\/$/u, "");
+
+// The generated client is configured here, in a module with no app
dependencies, rather
+// than next to the query client. Anything that issues a request while the app
is still
+// initializing has to observe the configured base -- i18n requests the
version at module
+// scope to build a cache buster -- and importing that request's module first
would
+// otherwise send it to the origin root instead of the prefix. Importing
anything derived
+// from the base href now orders this assignment ahead of the request.
+OpenAPI.BASE = baseHref.endsWith("/") ? baseHref.slice(0, -1) : baseHref;
+
+// Encode path params as full URI components so values containing "/" (e.g. a
variable key
+// like "/foo") become "%2Ffoo" rather than a literal "//", which proxies may
collapse. The
+// generated client otherwise defaults to encodeURI, which leaves "/"
untouched.
+// The backend automatically decodes path params.
+OpenAPI.ENCODE_PATH = encodeURIComponent;
diff --git a/airflow-core/src/airflow/ui/src/i18n/config.ts
b/airflow-core/src/airflow/ui/src/i18n/config.ts
index d9e7fae2ee1..50b7f6b7f3f 100644
--- a/airflow-core/src/airflow/ui/src/i18n/config.ts
+++ b/airflow-core/src/airflow/ui/src/i18n/config.ts
@@ -23,6 +23,10 @@ import { initReactI18next } from "react-i18next";
import { VersionService } from "openapi/requests/services.gen";
+// Also configures the generated client, which must happen before the version
+// request below. See src/basePath.
+import { basePath } from "src/basePath";
+
import { registerDayjsLocaleSync } from "./dayjsLocale";
export const supportedLanguages = [
@@ -61,10 +65,6 @@ export const namespaces = [
"hitl",
] as const;
-const baseHref = document.querySelector("head > base")?.getAttribute("href")
?? "";
-const baseUrl = new URL(baseHref, globalThis.location.origin);
-const basePath = new URL(baseUrl).pathname.replace(/\/$/u, "");
-
const supportedCodes: Array<string> = supportedLanguages.map((lang) =>
lang.code);
// i18next resolves navigator.languages with two global passes: it only reduces
diff --git a/airflow-core/src/airflow/ui/src/queryClient.test.ts
b/airflow-core/src/airflow/ui/src/queryClient.test.ts
new file mode 100644
index 00000000000..ea2c147278e
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/queryClient.test.ts
@@ -0,0 +1,65 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import axios from "axios";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+// Airflow can be served under a path prefix (`[api] base_url`), which the
+// server renders into `<base href>`. Every API request must be resolved
+// against it, otherwise requests land on the origin root, where a reverse
+// proxy in front of Airflow may route them to an entirely different service.
+const BASE_HREF = "/workflows/";
+
+describe("API client base path", () => {
+ beforeEach(() => {
+ vi.resetModules();
+ document.head.innerHTML = `<base href="${BASE_HREF}" />`;
+ });
+
+ afterEach(() => {
+ document.head.innerHTML = "";
+ vi.restoreAllMocks();
+ });
+
+ it("prefixes requests issued while the app is initializing", async () => {
+ const requested: Array<string> = [];
+
+ vi.spyOn(axios, "request").mockImplementation((config) => {
+ requested.push(String(config.url));
+
+ return Promise.resolve({
+ config,
+ data: { git_version: null, version: "3.3.1" },
+ headers: {},
+ status: 200,
+ statusText: "OK",
+ });
+ });
+
+ // Importing the query client pulls in the app's initialization chain. That
+ // chain includes i18n, which requests the version at module scope to build
+ // a cache buster -- so this request is issued before any component mounts.
+ await import("src/queryClient");
+
+ await vi.waitFor(() => {
+ expect(requested.length).toBeGreaterThan(0);
+ });
+
+ expect(requested.filter((url) => !url.startsWith(BASE_HREF))).toEqual([]);
+ });
+});
diff --git a/airflow-core/src/airflow/ui/src/queryClient.ts
b/airflow-core/src/airflow/ui/src/queryClient.ts
index f172736c848..fbe1638cd1b 100644
--- a/airflow-core/src/airflow/ui/src/queryClient.ts
+++ b/airflow-core/src/airflow/ui/src/queryClient.ts
@@ -18,25 +18,15 @@
*/
import { MutationCache, QueryClient } from "@tanstack/react-query";
-import { OpenAPI } from "openapi/requests/core/OpenAPI";
-
import { toaster } from "src/system-components";
+// Imported for its side effect: configures the generated client's base URL
and path
+// encoding. Kept in its own module so the configuration is applied before any
module
+// that requests something while the app is initializing. See src/basePath.
+import "src/basePath";
import i18n from "src/i18n/config";
import { getErrorStatus } from "src/utils";
-// Dynamically set the base URL for XHR requests based on the meta tag.
-OpenAPI.BASE = document.querySelector("head>base")?.getAttribute("href") ?? "";
-if (OpenAPI.BASE.endsWith("/")) {
- OpenAPI.BASE = OpenAPI.BASE.slice(0, -1);
-}
-
-// Encode path params as full URI components so values containing "/" (e.g. a
variable key like
-// "/foo") become "%2Ffoo" rather than a literal "//", which proxies may
collapse. The generated
-// client otherwise defaults to encodeURI, which leaves "/" untouched.
-// The backend automatically decodes path params.
-OpenAPI.ENCODE_PATH = encodeURIComponent;
-
const RETRY_COUNT = 3;
const retryFunction = (failureCount: number, error: unknown) => {