Skip to content

canonical(frontend): no single backend contract — 8 independent HTTP clients (2 generic) + 22 raw fetch sites, and generated api.ts is bypassed (4 importers vs 74 hand-typed responses) and stale (2424 vs 2152 paths) #12363

Description

@mrveiss

Summary

The frontend has no single source of truth for talking to the backend, in two dimensions:

  1. Transport: 8 independent HTTP-client classes (two of them generic), none extending a shared base, plus 22 files calling window.fetch directly and 3 importing axios directly.
  2. Types: a 172,402-line generated contract (src/types/generated/api.ts) that only 4 files import, while 74 files hand-type their own *Response interfaces. The generated file is also stale — it declares 2,424 paths against the live backend's 2,152.

This is the root cause under several already-filed issues (#12313 port/CSP, #12317 /api prefix, #12326 dead buttons): when every caller builds its own URL and declares its own response shape, nothing enforces that a path exists or that a response matches — and a config error like :8443 lands in some clients but not others, which is exactly why features break inconsistently.

1. HTTP-client sprawl

Eight distinct client classes, none of which extend the others — each reimplements request/error/auth/URL handling:

utils/ApiClient.ts                      class ApiClient        (generic)
services/api.ts                         class ApiService       (generic — second one)
utils/AdvancedControlApiClient.ts       class AdvancedControlApiClient
utils/SecretsApiClient(.d).ts           class SecretsApiClient
utils/VisionMultimodalApiClient.ts      class VisionMultimodalApiClient
utils/FeatureFlagsApiClient.ts          class FeatureFlagsApiClient
services/BatchApiService.ts             class BatchApiService
composables/useWorkflowBuilder.ts       class WorkflowBuilderApiClient

Plus 22 files using window.fetch directly and 3 importing axios directly, bypassing all of the above.

Two generic clients (ApiClient and ApiService) is itself an unresolved fork — there should be exactly one.

This is the mechanism behind the :8443 breakage (#12313)

Base-URL construction is not centralised, so it drifts per client:

54x  getApiBase()          # the common idiom
 4x  window.location...    # a different idiom
 1x  VITE_BACKEND_PORT     # raw — the :8443 defect lives here

Because there is no single place that builds the backend origin, the broken :8443 construction exists in some code paths and not others. That is precisely why chat/voice/onboarding break while other calls succeed on the same page — they are built by different clients. A single client would have made #12313 a one-line, one-place fix instead of a scattered symptom.

2. Types: the generated contract is bypassed and stale

src/types/generated/api.ts        172,402 lines, 2,424 path entries
  files importing it:                4
  files hand-typing *Response:      74
live backend (openapi.json):       2,152 paths
  • Bypassed: 74 hand-written response interfaces vs 4 files deriving from the contract. Example — models/repositories/KnowledgeRepository.ts:69 hand-declares RagSearchResponse rather than importing the generated shape.
  • Stale: the generated file has 272 more paths than the live backend serves, so even the 4 files that do use it may be typing against endpoints that no longer exist (or in shapes that have since changed). Regeneration is clearly not part of the backend-change workflow.

Per the canonical rule (#4): types come from the generated contract; a hand-typed response that duplicates a backend shape is a contract-check bypass. With 74 of them, TypeScript cannot catch a frontend/backend shape mismatch anywhere — which is why the 77 dead calls in #12326 compiled cleanly.

Why these two are one problem

Both are "the frontend invents its own version of the backend." Transport and types are the two halves of the contract, and both are currently reinvented per-file instead of derived once. Fixing them together is what makes the mechanical audit (audit_api_wiring.py) meaningful: a single client that only accepts paths present in the generated types would make an unwired call a compile error rather than a runtime 404.

Recommendation (architecture decision — needs owner sign-off)

Per the canonical rule for "two implementations already exist": pick one, migrate, shim/delete the rest — do not add a ninth.

  1. Declare one canonical client (likely utils/ApiClient.ts) that owns URL construction (one getApiBase, correct origin — fixes frontend: .env.production hardcodes VITE_BACKEND_PORT=8443 but nothing listens there — onboarding/metrics calls fail with 'Failed to fetch' #12313 at the source), auth, and error handling. Fold ApiService and the domain clients into thin typed wrappers over it, or delete them.
  2. Regenerate api.ts from the live backend and make regeneration a required step on any backend route change (npm run gen:types), enforced in CI so it cannot drift to 2,424-vs-2,152 again.
  3. Migrate the 74 hand-typed responses to the generated types, incrementally, starting with the repositories.
  4. Add a lint rule forbidding window.fetch/axios outside the canonical client, and forbidding hand-typed *Response interfaces where a generated equivalent exists.
  5. Record as an ADR — this is a module-boundary/authority decision.

Discovery issue — filed per the marketing-session rule (expose, do not fix). The migration is an implementation task for a later session and should not begin without the canonical-client decision in step 1.

Environment

autobot-frontend/src at origin/Dev_new_gui; counts from the deployed frontend source; live path count from the running backend's openapi.json.

Activity

  1. mrveiss commented on Jul 25, 2026

    @mrveiss
    OwnerAuthor

    Correction — the recommendation above under-scoped the canonical target

    The original recommendation framed this as "8 HTTP client classes → 1." That is necessary but not sufficient: it points at the wrong consolidation surface and omits the type-level half. Two dimensions were missing.

    1. Composables are the real data-access layer, not the client classes

    API access is spread across the composables, not centralised in the 8 client classes:

    composables total:                 218
    composables that make API calls:   125   (getApiBase / apiClient / fetch / axios)
    

    So consolidating the 8 client classes leaves 125 composables each still building their own calls, their own URL construction, and their own error handling. The canonical layering has to be explicit:

    2. Enums — 51 hand-typed string-unions that should derive from the contract

    The type half of the contract is reinvented the same way the transport half is. There are 51 hand-declared string-union types for status/type/mode/severity/level concepts, e.g.:

    type AgentDisplayStatus = 'active' | 'idle' | 'error' | 'paused' | 'terminated'
    type AutomationMode     = 'manual' | 'semi_automatic' | 'automatic'
    type AutoSaveStatus     = 'idle' | 'saving' | 'saved' | 'error'

    These are the frontend parallel of the backend PerformanceIssue str-vs-enum divergence called out in #12362: the same concept (e.g. an agent's status) exists as a backend enum, a generated-type enum, and a hand-typed frontend string union — three definitions, free to drift. A component checking status === 'terminated' silently breaks when the backend renames a variant, because nothing ties the string union to the source enum.

    Canonical fix: status/type/mode unions come from the generated types' enums, the same way response shapes should (original step 3). Hand-typed string-unions that duplicate a generated enum are the enum-level version of the hand-typed *Response bypass.

    Revised canonical target

    Not "one client," but the full contract derived once:

    1. One transport client — owns URL/auth/errors (unchanged).
    2. Composables as the sole API layer — components → composables → the one client; no component-level fetch/axios/client calls. This is where the 125-way sprawl converges.
    3. Types and enums from src/types/generated/api.ts — the 74 hand-typed *Response interfaces and the 51 hand-typed status/mode/type string-unions both migrate to the generated contract.
    4. Lint: forbid window.fetch/axios outside the client, hand-typed *Response where a generated shape exists, and hand-typed string-unions where a generated enum exists.
    5. ADR.

    The through-line with #12362 is exact: both are "one concept, defined multiple times, free to diverge (incl. str-vs-enum)." The fix is the same on both sides — derive from one source. Thanks for the catch; the original three-step version would have consolidated transport while leaving the composable sprawl and the enum divergence in place.

  2. mrveiss commented on Jul 26, 2026

    @mrveiss
    OwnerAuthor

    Phased plan (from a read-only audit) — issue is ~75% already done

    Key finding: a prior effort (#12152) already routed 6 of 8 clients through the canonical utils/ApiClient.ts. The 'no single contract' framing is now stale. Canonical = utils/ApiClient.ts (owns base-URL resolution, auth+expiry, X-Organization-Id, 401 handling, retry/backoff, timeout/abort, FormData, rawRequest) — ApiService already delegates to it.

    Remaining, phased:

    • Phase 1 (in progress): migrate the last 2 transport outliers — AdvancedControlApiClient (still on fetchWithAuth) → apiClient.rawRequest (behavior-neutral), and SecretsApiClient.js→.ts. Completes the single-transport half.
    • Phase 2: retire raw fetch (29 files) + axios (4 importers) in feature-batched PRs via fetchWithAuth/domain clients; add a lint rule forbidding raw fetch/axios. (SSE/streaming sites must use rawRequest, not .get().)
    • Phase 3: adopt generated src/types/generated/api.ts for the ~74 hand-typed *Response interfaces; gate gen:types/verify:types in CI. Fold in the oxlint glob fix (_generated vs generated mismatch makes oxlint walk a 5MB file every run).

    Owner decisions: whether AdvancedControl/Secrets should inherit ApiClient's retry + 401-auto-logout (behavior change — Phase 1 deliberately avoids it); whether to extract a shared @autobot/http-core (do NOT put transport in @autobot/ui #10860 — violates its presentational charter); making gen:types a CI gate.

  3. mrveiss commented on Jul 26, 2026

    @mrveiss
    OwnerAuthor

    Phase 1 complete (#12583 merged, discrete #12582 closed): the last 2 transport outliers now go through canonical ApiClient — the single-transport half of #12363 is done. Phases 2 (retire 29 raw-fetch + 4 axios) and 3 (adopt generated types + oxlint glob fix) remain per the roadmap above.

  4. 1 remaining item

  5. mrveiss commented on Aug 10, 2026

    @mrveiss
    OwnerAuthor

    Part of umbrella #13916 — canonical-source duplication

    Wave 3 — no canonical source chosen yet

    Larger than the others and needs an architecture decision on what the single backend contract is before any consolidation. Deliberately last so it does not block Waves 1-2.

    Depends on: Waves 1-2 should not wait on this.

    Umbrella acceptance criteria that apply to this issue

    1. Lands with a guard test in the shape of repo_tests/audio_extension_allowlist_test.py (tech-debt(transcriber): allowed-audio-extension set duplicated across three modules, one of them a security boundary #13512) — a
      test that fails when a new duplicate of the canonical definition appears. Migration without a guard
      regrows: refactor(types): consolidate 15+ Severity/RiskLevel/Level enums onto constants/status_enums.py:Severity #6689 consolidated 15+ severity enums and 15 exist again today.
    2. Deliberate exceptions are recorded, not omitted — where a copy is kept on purpose, the guard names it
      and says why.
    3. No silent behaviour change — where copies differ, reconciling them alters what the system accepts;
      that is an owner decision recorded before the change.
    4. The canonical source is discoverable from the copy site — the reason each of these was written twice.

    Update #13916 when this issue changes state

    Why these are grouped

    Five findings sharing one cause: a concept has no single authoritative definition, so subsystems each
    keep their own copy and the copies drift. The cost is already paid — a security boundary that drifted
    (#13512/#13615), a week of stale workers (#13539 via #13915), and a severity consolidation that regrew
    to 15 duplicates after being 'finished' (#6689 → #13597).

    #13512 is the worked example on the umbrella: three copies collapsed onto one constant, plus a guard
    test, plus the two deliberate exceptions written down and tracked separately rather than silently
    widening scope.

  6. modified the milestones: Backlog, v0.13.0 on Sep 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions