File size: 4,854 Bytes
34fef0e
 
 
 
 
 
071f9d4
 
1ae42b9
 
 
 
ca78461
1ae42b9
34fef0e
1ae42b9
34fef0e
1ae42b9
34fef0e
 
 
 
 
 
 
 
c10bcd9
 
34fef0e
 
 
 
1ae42b9
 
 
 
34fef0e
1ae42b9
34fef0e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1ae42b9
 
34fef0e
 
 
 
 
 
 
 
 
 
1ae42b9
 
 
 
 
 
34fef0e
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
import type { paths } from "./generated/api";

export type SessionResponse =
  paths["/api/v1/auth/session"]["get"]["responses"][200]["content"]["application/json"];
export type SystemResponse =
  paths["/api/v1/system"]["get"]["responses"][200]["content"]["application/json"];
export type NamespaceCapacity =
  paths["/api/v1/capacity"]["get"]["responses"][200]["content"]["application/json"];
export type RunList =
  paths["/api/v1/runs"]["get"]["responses"][200]["content"]["application/json"];
export type Run =
  paths["/api/v1/runs/{run_id}"]["get"]["responses"][200]["content"]["application/json"];
export type Capacity =
  paths["/api/v1/runs/{run_id}/capacity"]["get"]["responses"][200]["content"]["application/json"];
export type TaskList =
  paths["/api/v1/runs/{run_id}/tasks"]["get"]["responses"][200]["content"]["application/json"];
export type TaskDetail =
  paths["/api/v1/runs/{run_id}/tasks/{task_id}"]["get"]["responses"][200]["content"]["application/json"];
export type JobList =
  paths["/api/v1/jobs"]["get"]["responses"][200]["content"]["application/json"];
export type EndpointList =
  paths["/api/v1/endpoints"]["get"]["responses"][200]["content"]["application/json"];
export type ProfileList =
  paths["/api/v1/profiles"]["get"]["responses"][200]["content"]["application/json"];
export type ResultList =
  paths["/api/v1/results"]["get"]["responses"][200]["content"]["application/json"];
export type Leaderboard =
  paths["/api/v1/leaderboard"]["get"]["responses"][200]["content"]["application/json"];
export type ResultDetail =
  paths["/api/v1/results/{publication_id}"]["get"]["responses"][200]["content"]["application/json"];
export type AuditResponse =
  paths["/api/v1/audit"]["get"]["responses"][200]["content"]["application/json"];
export type RunSubmission =
  paths["/api/v1/runs"]["post"]["requestBody"]["content"]["application/json"];
export type RunAction =
  paths["/api/v1/runs/{run_id}/actions"]["post"]["requestBody"]["content"]["application/json"];
export type Accepted =
  paths["/api/v1/runs"]["post"]["responses"][202]["content"]["application/json"];

export class ApiError extends Error {
  constructor(
    readonly status: number,
    readonly code: string,
    message: string,
    readonly requestId: string | null = null,
    readonly retryAt: number | null = null,
  ) {
    super(message);
    this.name = "ApiError";
  }

  get transient(): boolean {
    return this.status === 0 || this.status === 429 || this.status >= 500;
  }
}

function retryAt(header: string | null): number | null {
  if (!header) return null;
  const seconds = Number(header);
  if (Number.isFinite(seconds) && seconds >= 0) return Date.now() + seconds * 1000;
  const date = Date.parse(header);
  return Number.isFinite(date) ? date : null;
}

function cookie(name: string): string | null {
  const prefix = `${encodeURIComponent(name)}=`;
  for (const item of document.cookie.split(";")) {
    const value = item.trim();
    if (value.startsWith(prefix)) return decodeURIComponent(value.slice(prefix.length));
  }
  return null;
}

export async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
  const headers = new Headers(init.headers);
  if (init.body) headers.set("Content-Type", "application/json");
  const csrf = cookie("hhf_csrf");
  if (csrf && init.method && !["GET", "HEAD"].includes(init.method))
    headers.set("X-CSRF-Token", csrf);
  let response: Response;
  try {
    response = await fetch(path, { ...init, headers, credentials: "same-origin" });
  } catch {
    throw new ApiError(
      0,
      "network_error",
      "The control service is unreachable. Check your connection and try again.",
    );
  }
  if (!response.ok) {
    const body = (await response.json().catch(() => null)) as {
      error?: { code?: string; message?: string; request_id?: string };
    } | null;
    throw new ApiError(
      response.status,
      body?.error?.code ?? "request_failed",
      body?.error?.message ?? `Request failed with ${response.status}`,
      body?.error?.request_id ?? null,
      retryAt(response.headers.get("Retry-After")),
    );
  }
  if (response.status === 204) return undefined as T;
  return response.json() as Promise<T>;
}

export async function submitRun(input: RunSubmission): Promise<Accepted> {
  return request<Accepted>("/api/v1/runs", {
    method: "POST",
    headers: { "Idempotency-Key": crypto.randomUUID() },
    body: JSON.stringify(input),
  });
}

export async function signOut(): Promise<void> {
  return request<void>("/auth/logout", { method: "POST" });
}

export async function actOnRun(runId: string, input: RunAction): Promise<Accepted> {
  return request<Accepted>(`/api/v1/runs/${encodeURIComponent(runId)}/actions`, {
    method: "POST",
    headers: { "Idempotency-Key": crypto.randomUUID() },
    body: JSON.stringify(input),
  });
}