interloper-api 0.57.0__tar.gz → 0.59.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {interloper_api-0.57.0 → interloper_api-0.59.0}/PKG-INFO +1 -1
- {interloper_api-0.57.0 → interloper_api-0.59.0}/pyproject.toml +1 -1
- {interloper_api-0.57.0 → interloper_api-0.59.0}/pyproject.toml.orig +1 -1
- {interloper_api-0.57.0 → interloper_api-0.59.0}/src/interloper_api/app.py +11 -1
- {interloper_api-0.57.0 → interloper_api-0.59.0}/src/interloper_api/dependencies.py +20 -0
- {interloper_api-0.57.0 → interloper_api-0.59.0}/src/interloper_api/routes/admin.py +189 -4
- {interloper_api-0.57.0 → interloper_api-0.59.0}/src/interloper_api/routes/backfills.py +33 -9
- {interloper_api-0.57.0 → interloper_api-0.59.0}/README.md +0 -0
- {interloper_api-0.57.0 → interloper_api-0.59.0}/src/interloper_api/__init__.py +0 -0
- {interloper_api-0.57.0 → interloper_api-0.59.0}/src/interloper_api/components.py +0 -0
- {interloper_api-0.57.0 → interloper_api-0.59.0}/src/interloper_api/email.py +0 -0
- {interloper_api-0.57.0 → interloper_api-0.59.0}/src/interloper_api/routes/__init__.py +0 -0
- {interloper_api-0.57.0 → interloper_api-0.59.0}/src/interloper_api/routes/agent.py +0 -0
- {interloper_api-0.57.0 → interloper_api-0.59.0}/src/interloper_api/routes/auth.py +0 -0
- {interloper_api-0.57.0 → interloper_api-0.59.0}/src/interloper_api/routes/catalog.py +0 -0
- {interloper_api-0.57.0 → interloper_api-0.59.0}/src/interloper_api/routes/components.py +0 -0
- {interloper_api-0.57.0 → interloper_api-0.59.0}/src/interloper_api/routes/oauth.py +0 -0
- {interloper_api-0.57.0 → interloper_api-0.59.0}/src/interloper_api/routes/organisations.py +0 -0
- {interloper_api-0.57.0 → interloper_api-0.59.0}/src/interloper_api/routes/runs.py +0 -0
- {interloper_api-0.57.0 → interloper_api-0.59.0}/src/interloper_api/routes/tokens.py +0 -0
- {interloper_api-0.57.0 → interloper_api-0.59.0}/src/interloper_api/routes/ws.py +0 -0
|
@@ -9,7 +9,7 @@ from fastapi import APIRouter, FastAPI, Request
|
|
|
9
9
|
from fastapi.middleware.cors import CORSMiddleware
|
|
10
10
|
from fastapi.responses import JSONResponse
|
|
11
11
|
from interloper.catalog.base import Catalog
|
|
12
|
-
from interloper.errors import ComponentDriftError, NotFoundError
|
|
12
|
+
from interloper.errors import ComponentDriftError, NotFoundError, QuotaExceededError
|
|
13
13
|
from interloper_db import Store
|
|
14
14
|
|
|
15
15
|
from interloper_api.dependencies import (
|
|
@@ -17,6 +17,7 @@ from interloper_api.dependencies import (
|
|
|
17
17
|
set_auth_config,
|
|
18
18
|
set_catalog,
|
|
19
19
|
set_features,
|
|
20
|
+
set_quota_defaults,
|
|
20
21
|
set_smtp_config,
|
|
21
22
|
set_store,
|
|
22
23
|
)
|
|
@@ -76,6 +77,14 @@ def create_app(
|
|
|
76
77
|
"""
|
|
77
78
|
return JSONResponse(status_code=409, content={"detail": str(exc)})
|
|
78
79
|
|
|
80
|
+
@app.exception_handler(QuotaExceededError)
|
|
81
|
+
async def _quota_exceeded_handler(_request: Request, exc: QuotaExceededError) -> JSONResponse:
|
|
82
|
+
"""Store-level quota enforcement surfaces as 429 with structured context."""
|
|
83
|
+
return JSONResponse(
|
|
84
|
+
status_code=429,
|
|
85
|
+
content={"detail": {"message": str(exc), "quota": exc.quota, "limit": exc.limit, "used": exc.used}},
|
|
86
|
+
)
|
|
87
|
+
|
|
79
88
|
if cors_origins:
|
|
80
89
|
app.add_middleware(
|
|
81
90
|
CORSMiddleware,
|
|
@@ -92,6 +101,7 @@ def create_app(
|
|
|
92
101
|
if settings:
|
|
93
102
|
set_auth_config(settings.auth)
|
|
94
103
|
set_smtp_config(settings.smtp)
|
|
104
|
+
set_quota_defaults(settings.quota)
|
|
95
105
|
|
|
96
106
|
api = APIRouter(prefix="/api")
|
|
97
107
|
api.include_router(auth.router)
|
|
@@ -18,6 +18,7 @@ _auth_config: Any | None = None
|
|
|
18
18
|
_smtp_config: Any | None = None
|
|
19
19
|
_features: dict[str, bool] = {}
|
|
20
20
|
_admin_config: Any | None = None
|
|
21
|
+
_quota_defaults: Any | None = None
|
|
21
22
|
|
|
22
23
|
# Role hierarchy: admin > editor > viewer
|
|
23
24
|
_ROLE_RANK = {"viewer": 0, "editor": 1, "admin": 2}
|
|
@@ -154,6 +155,25 @@ def get_admin_config() -> Any:
|
|
|
154
155
|
return _admin_config
|
|
155
156
|
|
|
156
157
|
|
|
158
|
+
def set_quota_defaults(defaults: Any) -> None:
|
|
159
|
+
"""Set the global default quota limits (from settings, at app creation).
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
defaults: The QuotaSettings instance.
|
|
163
|
+
"""
|
|
164
|
+
global _quota_defaults # noqa: PLW0603
|
|
165
|
+
_quota_defaults = defaults
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def get_quota_defaults() -> Any:
|
|
169
|
+
"""Return the global default quota limits.
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
The QuotaSettings instance, or None if not configured.
|
|
173
|
+
"""
|
|
174
|
+
return _quota_defaults
|
|
175
|
+
|
|
176
|
+
|
|
157
177
|
# -- Auth dependencies -------------------------------------------------------
|
|
158
178
|
|
|
159
179
|
|
|
@@ -8,6 +8,7 @@ no access to org-scoped *data* (sources, jobs, runs, …).
|
|
|
8
8
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
+
import datetime as dt
|
|
11
12
|
import inspect
|
|
12
13
|
import logging
|
|
13
14
|
from datetime import datetime
|
|
@@ -17,9 +18,10 @@ from uuid import UUID
|
|
|
17
18
|
|
|
18
19
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
19
20
|
from interloper_db import Organisation, Profile, Store
|
|
20
|
-
from
|
|
21
|
+
from interloper_db.store.quotas import METRIC_SUCCESSFUL_RUNS
|
|
22
|
+
from pydantic import BaseModel, Field
|
|
21
23
|
|
|
22
|
-
from interloper_api.dependencies import get_admin_config, get_store, require_super_admin
|
|
24
|
+
from interloper_api.dependencies import get_admin_config, get_quota_defaults, get_store, require_super_admin
|
|
23
25
|
from interloper_api.email import send_invite_email
|
|
24
26
|
|
|
25
27
|
logger = logging.getLogger(__name__)
|
|
@@ -39,6 +41,9 @@ class AdminOrganisationResponse(BaseModel):
|
|
|
39
41
|
name: str
|
|
40
42
|
member_count: int
|
|
41
43
|
created_at: datetime | None = None
|
|
44
|
+
#: Soft-deleted orgs stay listed (their retained history and ledger are
|
|
45
|
+
#: still attributable) but are no longer manageable.
|
|
46
|
+
deleted_at: datetime | None = None
|
|
42
47
|
|
|
43
48
|
|
|
44
49
|
class AdminUserOrganisation(BaseModel):
|
|
@@ -223,6 +228,57 @@ class AdminDataConfig(BaseModel):
|
|
|
223
228
|
catalog: dict[str, list[str]]
|
|
224
229
|
|
|
225
230
|
|
|
231
|
+
class AdminQuotaLimits(BaseModel):
|
|
232
|
+
"""One set of quota limits; null means unlimited (or unset, for overrides)."""
|
|
233
|
+
|
|
234
|
+
max_sources: int | None = None
|
|
235
|
+
max_assets_per_source: int | None = None
|
|
236
|
+
max_successful_runs_per_month: int | None = None
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class AdminOrgQuotaStatus(BaseModel):
|
|
240
|
+
"""One organisation's quota limits and current usage."""
|
|
241
|
+
|
|
242
|
+
id: UUID
|
|
243
|
+
name: str
|
|
244
|
+
#: Soft-deleted orgs keep their ledger visible but are read-only.
|
|
245
|
+
deleted_at: datetime | None = None
|
|
246
|
+
limits: AdminQuotaLimits
|
|
247
|
+
effective: AdminQuotaLimits
|
|
248
|
+
sources: int
|
|
249
|
+
max_assets_per_source: int
|
|
250
|
+
successful_runs: int
|
|
251
|
+
reserved_runs: int
|
|
252
|
+
#: Recomputed from the runs table; a mismatch with ``successful_runs``
|
|
253
|
+
#: (the ledger) signals counter drift.
|
|
254
|
+
recomputed_successful_runs: int
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
class AdminQuotasResponse(BaseModel):
|
|
258
|
+
"""Quota overview: global defaults plus per-organisation status."""
|
|
259
|
+
|
|
260
|
+
period_start: dt.date
|
|
261
|
+
defaults: AdminQuotaLimits
|
|
262
|
+
organisations: list[AdminOrgQuotaStatus]
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
class AdminQuotaUpdateRequest(BaseModel):
|
|
266
|
+
"""Per-org quota overrides. Omitted fields keep their value; null clears one."""
|
|
267
|
+
|
|
268
|
+
max_sources: int | None = Field(default=None, ge=0)
|
|
269
|
+
max_assets_per_source: int | None = Field(default=None, ge=0)
|
|
270
|
+
max_successful_runs_per_month: int | None = Field(default=None, ge=0)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
class AdminActivityEntry(BaseModel):
|
|
274
|
+
"""One event in an organisation's derived activity feed."""
|
|
275
|
+
|
|
276
|
+
kind: str
|
|
277
|
+
when: datetime
|
|
278
|
+
title: str
|
|
279
|
+
detail: str | None = None
|
|
280
|
+
|
|
281
|
+
|
|
226
282
|
class AdminConfigResponse(BaseModel):
|
|
227
283
|
"""Read-only, secrets-redacted snapshot of the instance configuration."""
|
|
228
284
|
|
|
@@ -230,6 +286,7 @@ class AdminConfigResponse(BaseModel):
|
|
|
230
286
|
auth: AdminAuthConfig
|
|
231
287
|
services: AdminServicesConfig
|
|
232
288
|
data: AdminDataConfig
|
|
289
|
+
quotas: AdminQuotaLimits
|
|
233
290
|
|
|
234
291
|
|
|
235
292
|
# Non-secret launcher/runner config keys exposed on /admin/config. Anything not
|
|
@@ -400,12 +457,41 @@ def build_config_snapshot(settings: Any, features: dict[str, bool], catalog: Any
|
|
|
400
457
|
encryption_configured=bool(settings.secrets.encryption_key),
|
|
401
458
|
catalog=_catalog_by_kind(catalog) if catalog is not None else {},
|
|
402
459
|
),
|
|
460
|
+
quotas=_quota_limits(settings.quota),
|
|
403
461
|
)
|
|
404
462
|
|
|
405
463
|
|
|
406
464
|
# -- Helpers ------------------------------------------------------------------
|
|
407
465
|
|
|
408
466
|
|
|
467
|
+
def _quota_limits(limits: Any) -> AdminQuotaLimits:
|
|
468
|
+
"""Map limits from a ``{key: limit}`` dict (store) or attributes (settings)."""
|
|
469
|
+
if isinstance(limits, dict):
|
|
470
|
+
return AdminQuotaLimits(**{key: limits.get(key) for key in AdminQuotaLimits.model_fields})
|
|
471
|
+
return AdminQuotaLimits(
|
|
472
|
+
max_sources=getattr(limits, "max_sources", None),
|
|
473
|
+
max_assets_per_source=getattr(limits, "max_assets_per_source", None),
|
|
474
|
+
max_successful_runs_per_month=getattr(limits, "max_successful_runs_per_month", None),
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _effective_limits(overrides: AdminQuotaLimits, defaults: AdminQuotaLimits) -> AdminQuotaLimits:
|
|
479
|
+
"""Per-org overrides win field-by-field over the global defaults."""
|
|
480
|
+
return AdminQuotaLimits(
|
|
481
|
+
max_sources=overrides.max_sources if overrides.max_sources is not None else defaults.max_sources,
|
|
482
|
+
max_assets_per_source=(
|
|
483
|
+
overrides.max_assets_per_source
|
|
484
|
+
if overrides.max_assets_per_source is not None
|
|
485
|
+
else defaults.max_assets_per_source
|
|
486
|
+
),
|
|
487
|
+
max_successful_runs_per_month=(
|
|
488
|
+
overrides.max_successful_runs_per_month
|
|
489
|
+
if overrides.max_successful_runs_per_month is not None
|
|
490
|
+
else defaults.max_successful_runs_per_month
|
|
491
|
+
),
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
|
|
409
495
|
def _require_org(store: Store, org_id: UUID) -> Organisation:
|
|
410
496
|
"""Fetch an organisation or raise 404."""
|
|
411
497
|
org = store.get_organisation(org_id)
|
|
@@ -466,6 +552,100 @@ def get_instance_config(
|
|
|
466
552
|
return config
|
|
467
553
|
|
|
468
554
|
|
|
555
|
+
# -- Quotas ---------------------------------------------------------------------
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
@router.get("/quotas")
|
|
559
|
+
def get_quotas(
|
|
560
|
+
user: Profile = Depends(require_super_admin),
|
|
561
|
+
store: Store = Depends(get_store),
|
|
562
|
+
quota_defaults: Any = Depends(get_quota_defaults),
|
|
563
|
+
) -> AdminQuotasResponse:
|
|
564
|
+
"""Quota limits and current-period usage for every organisation."""
|
|
565
|
+
defaults = _quota_limits(quota_defaults)
|
|
566
|
+
period_start = store.current_period_start()
|
|
567
|
+
overrides = store.list_quota_overrides()
|
|
568
|
+
usage = {
|
|
569
|
+
row.org_id: row
|
|
570
|
+
for row in store.list_usage(period_start=period_start)
|
|
571
|
+
if row.metric == METRIC_SUCCESSFUL_RUNS
|
|
572
|
+
}
|
|
573
|
+
sources = store.count_sources_by_org()
|
|
574
|
+
max_assets = store.max_assets_per_source_by_org()
|
|
575
|
+
recomputed = store.count_successful_runs_by_org(period_start)
|
|
576
|
+
|
|
577
|
+
organisations = []
|
|
578
|
+
for org, _member_count in store.list_all_organisations():
|
|
579
|
+
limits = _quota_limits(overrides.get(org.id, {}))
|
|
580
|
+
org_usage = usage.get(org.id)
|
|
581
|
+
organisations.append(
|
|
582
|
+
AdminOrgQuotaStatus(
|
|
583
|
+
id=org.id,
|
|
584
|
+
name=org.name,
|
|
585
|
+
deleted_at=org.deleted_at,
|
|
586
|
+
limits=limits,
|
|
587
|
+
effective=_effective_limits(limits, defaults),
|
|
588
|
+
sources=sources.get(org.id, 0),
|
|
589
|
+
max_assets_per_source=max_assets.get(org.id, 0),
|
|
590
|
+
successful_runs=org_usage.used if org_usage else 0,
|
|
591
|
+
reserved_runs=org_usage.reserved if org_usage else 0,
|
|
592
|
+
recomputed_successful_runs=recomputed.get(org.id, 0),
|
|
593
|
+
)
|
|
594
|
+
)
|
|
595
|
+
return AdminQuotasResponse(period_start=period_start, defaults=defaults, organisations=organisations)
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _activity_title(entry: dict[str, Any]) -> tuple[str, str | None]:
|
|
599
|
+
"""Presentation strings for a derived activity entry."""
|
|
600
|
+
kind, subject, extra = entry["kind"], entry["subject"], entry["extra"]
|
|
601
|
+
if kind == "org_created":
|
|
602
|
+
return "Organisation created", None
|
|
603
|
+
if kind == "org_deleted":
|
|
604
|
+
return "Organisation deleted", "Retained read-only for billing history."
|
|
605
|
+
if kind == "member_joined":
|
|
606
|
+
return f"{subject} joined the organisation", f"Role: {extra}" if extra else None
|
|
607
|
+
if kind == "invitation_sent":
|
|
608
|
+
return f"Invitation sent to {subject}", f"Invited by {extra}" if extra else None
|
|
609
|
+
if kind == "source_added":
|
|
610
|
+
return f"Source added — {subject}", None
|
|
611
|
+
if kind == "runs_completed":
|
|
612
|
+
count = int(subject)
|
|
613
|
+
return f"{count:,} run{'' if count == 1 else 's'} completed successfully", None
|
|
614
|
+
return kind, None
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
@router.get("/organisations/{org_id}/activity")
|
|
618
|
+
def get_organisation_activity(
|
|
619
|
+
org_id: UUID,
|
|
620
|
+
user: Profile = Depends(require_super_admin),
|
|
621
|
+
store: Store = Depends(get_store),
|
|
622
|
+
) -> list[AdminActivityEntry]:
|
|
623
|
+
"""Derived activity feed for one organisation, newest first.
|
|
624
|
+
|
|
625
|
+
Composed from existing records (memberships, pending invitations,
|
|
626
|
+
sources, run aggregates, the org row itself) — there is no audit
|
|
627
|
+
trail, so actor attribution is limited to what those rows carry.
|
|
628
|
+
"""
|
|
629
|
+
entries = []
|
|
630
|
+
for entry in store.list_organisation_activity(org_id):
|
|
631
|
+
title, detail = _activity_title(entry)
|
|
632
|
+
entries.append(AdminActivityEntry(kind=entry["kind"], when=entry["when"], title=title, detail=detail))
|
|
633
|
+
return entries
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
@router.patch("/organisations/{org_id}/quota")
|
|
637
|
+
def update_org_quota(
|
|
638
|
+
org_id: UUID,
|
|
639
|
+
body: AdminQuotaUpdateRequest,
|
|
640
|
+
user: Profile = Depends(require_super_admin),
|
|
641
|
+
store: Store = Depends(get_store),
|
|
642
|
+
) -> AdminQuotaLimits:
|
|
643
|
+
"""Set an organisation's quota overrides; omitted fields keep their value, null clears one."""
|
|
644
|
+
_require_org(store, org_id)
|
|
645
|
+
overrides = store.set_quota(org_id, body.model_dump(exclude_unset=True))
|
|
646
|
+
return _quota_limits(overrides)
|
|
647
|
+
|
|
648
|
+
|
|
469
649
|
# -- Users --------------------------------------------------------------------
|
|
470
650
|
|
|
471
651
|
|
|
@@ -510,13 +690,14 @@ def list_all_organisations(
|
|
|
510
690
|
user: Profile = Depends(require_super_admin),
|
|
511
691
|
store: Store = Depends(get_store),
|
|
512
692
|
) -> list[AdminOrganisationResponse]:
|
|
513
|
-
"""List every organisation with its member count."""
|
|
693
|
+
"""List every organisation with its member count, soft-deleted ones included."""
|
|
514
694
|
return [
|
|
515
695
|
AdminOrganisationResponse(
|
|
516
696
|
id=org.id,
|
|
517
697
|
name=org.name,
|
|
518
698
|
member_count=count,
|
|
519
699
|
created_at=org.created_at,
|
|
700
|
+
deleted_at=org.deleted_at,
|
|
520
701
|
)
|
|
521
702
|
for org, count in store.list_all_organisations()
|
|
522
703
|
]
|
|
@@ -563,7 +744,11 @@ def delete_organisation(
|
|
|
563
744
|
user: Profile = Depends(require_super_admin),
|
|
564
745
|
store: Store = Depends(get_store),
|
|
565
746
|
) -> dict[str, str]:
|
|
566
|
-
"""
|
|
747
|
+
"""Soft-delete an organisation: purges its data, keeps execution history and the usage ledger.
|
|
748
|
+
|
|
749
|
+
The body must repeat the exact name. Deleted organisations stay in the
|
|
750
|
+
list (read-only) and read as missing everywhere else.
|
|
751
|
+
"""
|
|
567
752
|
org = _require_org(store, org_id)
|
|
568
753
|
if body.name != org.name:
|
|
569
754
|
raise HTTPException(status_code=400, detail="Organisation name does not match")
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Backfills API:
|
|
1
|
+
"""Backfills API: queue, inspect, and cancel backfills."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
@@ -100,14 +100,17 @@ def create_backfill(
|
|
|
100
100
|
job = load_authorized(
|
|
101
101
|
lambda i: store.get_component(i, kind="job"), body.component_id, user, store, label="Job", minimum="editor"
|
|
102
102
|
)
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
103
|
+
try:
|
|
104
|
+
backfill = store.create_backfill(
|
|
105
|
+
job.org_id,
|
|
106
|
+
component_id=body.component_id,
|
|
107
|
+
start_date=body.start_date,
|
|
108
|
+
end_date=body.end_date,
|
|
109
|
+
concurrency=body.concurrency,
|
|
110
|
+
fail_fast=body.fail_fast,
|
|
111
|
+
)
|
|
112
|
+
except ValueError as e: # span cap
|
|
113
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
111
114
|
return _backfill_to_response(backfill)
|
|
112
115
|
|
|
113
116
|
|
|
@@ -124,3 +127,24 @@ def get_backfill(
|
|
|
124
127
|
raise HTTPException(status_code=404, detail=f"Backfill {backfill_id} not found")
|
|
125
128
|
authorize_org_member(user, backfill.org_id, store, detail=f"Backfill {backfill_id} not found")
|
|
126
129
|
return _backfill_to_response(backfill)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@router.post("/{backfill_id}/cancel")
|
|
133
|
+
def cancel_backfill(
|
|
134
|
+
backfill_id: UUID,
|
|
135
|
+
user: Profile = Depends(get_current_user),
|
|
136
|
+
store: Store = Depends(get_store),
|
|
137
|
+
) -> BackfillResponse:
|
|
138
|
+
"""Cancel a backfill: pending and queued runs are canceled, in-flight runs drain."""
|
|
139
|
+
try:
|
|
140
|
+
backfill = store.get_backfill(backfill_id)
|
|
141
|
+
except NotFoundError:
|
|
142
|
+
raise HTTPException(status_code=404, detail=f"Backfill {backfill_id} not found")
|
|
143
|
+
authorize_org_member(
|
|
144
|
+
user, backfill.org_id, store, minimum="editor", detail=f"Backfill {backfill_id} not found"
|
|
145
|
+
)
|
|
146
|
+
try:
|
|
147
|
+
backfill = store.cancel_backfill(backfill_id)
|
|
148
|
+
except ValueError as e:
|
|
149
|
+
raise HTTPException(status_code=409, detail=str(e))
|
|
150
|
+
return _backfill_to_response(backfill)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|