Typed Requests & Errors
Any endpoint, typed
lh.GET, lh.POST, lh.PUT, lh.PATCH and lh.DELETE take an API path exactly as the
API reference lists it. Paths autocomplete, parameters and bodies are checked, and the result is typed
from the endpoint's response schema.
// Path and query parameters
const meta = await lh.GET('/api/v1/courses/{course_uuid}/meta', {
params: { path: { course_uuid: 'course_…' }, query: { slim: true } },
})
// JSON body
const chapter = await lh.POST('/api/v1/chapters/', {
body: { name: 'Week 2', course_id: 12, org_id: 1 },
})A missing path parameter, a wrong type or an unknown path is a compile error. Calls return the response data directly, or throw.
Files and forms
Pass FormData as the body for uploads, or bodyType: 'form' to send a plain object as form fields:
const form = new FormData()
form.append('thumbnail', file)
await lh.PUT('/api/v1/courses/{course_uuid}/thumbnail', {
params: { path: { course_uuid: 'course_…' } },
body: form,
})Other response formats
parseAs reads the body as 'json' (default for JSON), 'text', 'blob', 'arrayBuffer', 'stream', or gives you the
raw 'response'.
Endpoints outside the schema
lh.request(method, path, options) sends any request with the same auth, retries and errors, without types.
Errors
Every failure throws a LearnHouseError:
import { LearnHouseError, isLearnHouseError } from '@learnhouse/sdk'
try {
await lh.activities.get('activity_…')
} catch (err) {
if (isLearnHouseError(err)) {
console.log(err.status) // 404
console.log(err.code) // 'HTTP_ERROR', or the API's own code like 'RATE_LIMITED'
console.log(err.detail) // the API's `detail`
} else {
throw err
}
}| Field | |
|---|---|
status | HTTP status (0 when there was no response) |
code | HTTP_ERROR, NETWORK_ERROR, TIMEOUT, PARSE_ERROR, VALIDATION_ERROR, or the code the API sent |
detail | The API's detail: a message, an object, or a list of validation errors |
method, path, url | The request that failed |
body, cause | The raw error body, and the underlying error for network failures |
retryAfterMs | How long the API asked you to wait, when it sent Retry-After (on 429 and 503) |
isLearnHouseError also recognises errors from another copy of the SDK (two bundles on one page).
Timeouts, retries, cancellation
const lh = createClient({
baseUrl,
timeoutMs: 15_000, // per attempt, body included. 0 turns it off
retry: 3, // or { retries, baseDelayMs, maxDelayMs }. false turns it off
})
// Per call
const controller = new AbortController()
await lh.courses.getMeta('course_…', { signal: controller.signal, timeoutMs: 5_000, retry: false })The SDK retries:
GETandHEADrequests that hit a network error, a timeout or a5xx.- Any request that gets a
429 Too Many Requests. The API refused it before doing anything, so sending it again is safe.
Retries back off exponentially, starting at baseDelayMs (300 ms) and capped at maxDelayMs (5 s). Other requests
that change data are never retried, so nothing is applied twice.
Aborting stops the request, and any wait between retries, and throws the platform's AbortError, not a
LearnHouseError.
Rate limits
When a 429 or 503 carries a Retry-After header, the SDK waits exactly that long before the next attempt, instead
of its own backoff. Both forms work: seconds (Retry-After: 30) and an HTTP date.
If the API asks for a longer wait than maxDelayMs, the SDK does not retry early, since that only earns another
429. It throws right away, and err.retryAfterMs tells you when to try again:
try {
await lh.search.org('your-school', 'payouts')
} catch (err) {
if (isLearnHouseError(err) && err.status === 429) {
console.log(`Try again in ${err.retryAfterMs ?? 1000} ms`)
} else {
throw err
}
}Raise maxDelayMs if your code can afford to wait longer, for example in a background job.
Pagination
lh.courses.listAll() walks every page for you. Use it with for await:
for await (const course of lh.courses.listAll('your-school')) {
console.log(course.name)
}It asks for 100 courses per request (the API's maximum) and stops after the first page that comes back short. It
never requests a page past the end. break out of the loop and no further page is fetched.
For other endpoints that take a page and a limit, use paginate(). Give it a function that fetches one page:
import { paginate } from '@learnhouse/sdk'
const controller = new AbortController()
const pages = paginate(
({ page, pageSize, signal }) => lh.courses.list('your-school', { page, limit: pageSize, signal }),
{ pageSize: 50, signal: controller.signal },
)
for await (const course of pages) {
if (course.name === 'Payouts') break
}| Option | Default | |
|---|---|---|
pageSize | 20 | Items per request. Keep it at or under the endpoint's maximum, or a capped page reads as the last one |
startPage | 1 | First page to fetch |
signal | Stops between pages. Pass it to your own call too, as above |
Search results are paged per result type, so they have their own iterator. See Search.
Hooks
onRequest runs before each attempt and can change the request; onResponse sees every response.
const lh = createClient({
baseUrl,
token,
onRequest: (ctx) => {
ctx.init.headers.set('X-Request-Id', crypto.randomUUID())
},
onResponse: (ctx) => {
metrics.timing('learnhouse.request', ctx.path, ctx.response.status)
},
})ctx.path is the path template (/api/v1/courses/{course_uuid}), which makes it a good metrics label.
Custom fetch
Pass fetch to use your own implementation: a proxy-aware one in Node, Next.js's cached fetch, or a mock in tests.
const lh = createClient({ baseUrl, token, fetch: (url, init) => fetch(url, { ...init, next: { revalidate: 60 } }) })Types come from the API of the release the SDK was published with. If your instance is older, a few newer endpoints may not exist on it yet; see Versions & releases.