Skip to content

Commit e4806a0

Browse files
committed
feat: handle ratelimits + GET tile 404s
1 parent f1dd0bc commit e4806a0

3 files changed

Lines changed: 78 additions & 35 deletions

File tree

src/main.ts

Lines changed: 74 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { mkdir, writeFile } from "node:fs/promises"
44
import { dirname } from "node:path"
55
import { PNG } from "pngjs"
66
import { err, ok, type Result } from "./result.js"
7-
import { Pixel, RandomPixel } from "./types.js"
7+
import { Pixel, RandomPixel, type HeaderMap } from "./types.js"
88
import { type } from "arktype"
99

1010
/**
@@ -26,15 +26,27 @@ export default class WplaceAPI {
2626

2727
/**
2828
* Download a tile and save it to a file
29+
*
30+
* Ok(true) means the tile was downloaded and saved successfully.
31+
*
32+
* Ok(false) means the tile was empty (404).
33+
*
34+
* Err(Error) means there was an error during the process.
2935
* @param tileX x-coordinate of tile
3036
* @param tileY y-coordinate of tile
3137
* @param path path to save the tile to
32-
* @returns Ok(void) on success, Err(Error) on failure
38+
* @returns Ok(boolean) on success, Err(Error) on failure
3339
*/
34-
async downloadTile(tileX: number, tileY: number, path: string): Promise<Result<void, Error>> {
35-
const res = await this.getPlain(format(ROUTES.GET_TILE, tileX, tileY))
40+
async downloadTile(tileX: number, tileY: number, path: string): Promise<Result<boolean, Error>> {
41+
const result = await this.getPlain(format(ROUTES.GET_TILE, tileX, tileY))
42+
if (!result.ok) return result
43+
44+
const res = result.value
3645

3746
if (!res.ok) {
47+
if (res.status == 404) {
48+
return ok(false) // tile empty
49+
}
3850
return err(new Error(`Failed to download tile. Status: ${res.status}`))
3951
}
4052

@@ -43,31 +55,40 @@ export default class WplaceAPI {
4355

4456
await saveFile(path, buffer)
4557

46-
console.log(`Downloaded tile at (${tileX}:${tileY}) to ${path}`)
47-
48-
return ok()
58+
return ok(true)
4959
}
5060

5161
/**
5262
* Get a tile as a PNG object
63+
*
64+
* Ok(PNG) means the tile was downloaded successfully.
65+
*
66+
* Ok(undefined) means the tile was empty (404).
67+
*
68+
* Err(Error) means there was an error during the process.
69+
*
5370
* @param tileX x-coordinate of tile
5471
* @param tileY y-coordinate of tile
55-
* @returns Ok(PNG) on success, Err(Error) on failure
72+
* @returns Ok(PNG | undefined) on success, Err(Error) on failure
5673
* @see {@link https://www.npmjs.com/package/pngjs}
5774
*/
58-
async getTile(tileX: number, tileY: number): Promise<Result<PNG, Error>> {
59-
const res = await this.getPlain(format(ROUTES.GET_TILE, tileX, tileY))
75+
async getTile(tileX: number, tileY: number): Promise<Result<PNG | undefined, Error>> {
76+
const result = await this.getPlain(format(ROUTES.GET_TILE, tileX, tileY))
77+
if (!result.ok) return result
78+
79+
const res = result.value
6080

6181
if (!res.ok) {
82+
if (res.status == 404) {
83+
return ok(undefined) // tile empty
84+
}
6285
return err(new Error(`Failed to download tile. Status: ${res.status}`))
6386
}
6487

6588
const arrayBuffer = await res.arrayBuffer()
6689
const buffer = Buffer.from(arrayBuffer)
6790
const png = PNG.sync.read(buffer)
6891

69-
console.log(`Got tile at (${tileX}:${tileY})`)
70-
7192
return ok(png)
7293
}
7394

@@ -128,15 +149,30 @@ export default class WplaceAPI {
128149
* @param route API route to fetch
129150
* @returns Response object
130151
*/
131-
private async getPlain(route: string): Promise<Response> {
132-
console.group(`GET ${route}`)
133-
const res = await fetch(`${this.options.API_ROOT}${route}`, {
134-
method: "GET"
135-
})
136-
console.log(`${res.status} ${res.statusText} (${res.headers.get("Content-Type")})`)
137-
138-
console.groupEnd()
139-
return res
152+
private async getPlain(route: string, headers: HeaderMap = {}): Promise<Result<Response, Error>> {
153+
let res: Response
154+
155+
try {
156+
res = await fetch(`${this.options.API_ROOT}${route}`, {
157+
method: "GET",
158+
headers: {
159+
...headers,
160+
"User-Agent": this.options.userAgent
161+
}
162+
})
163+
} catch (e) {
164+
return err(e as Error)
165+
}
166+
167+
if (!res.ok && res.status == 429) {
168+
const retryAfter = res.headers.get("Retry-After")
169+
const waitTime = retryAfter ? parseInt(retryAfter) * 1000 : this.options.defaultRetryAfter
170+
//console.log(`Rate limited. Retrying after ${waitTime}ms...`)
171+
172+
return sleep(waitTime).then(() => this.getPlain(route, headers))
173+
}
174+
175+
return ok(res)
140176
}
141177

142178
/**
@@ -145,23 +181,18 @@ export default class WplaceAPI {
145181
* @param headers optional HTTP headers
146182
* @returns Ok({ res, data }) on success, Err(Error) on failure
147183
*/
148-
private async get(route: string, headers?: Headers): Promise<Result<{ res: Response, data: unknown }, Error>> {
149-
console.group(`GET ${route}`)
150-
const res = await fetch(`${this.options.API_ROOT}${route}`, {
151-
method: "GET",
152-
headers: headers ?? {}
153-
})
154-
console.log(`${res.status} ${res.statusText} (${res.headers.get("Content-Type")})`)
184+
private async get(route: string, headers?: HeaderMap): Promise<Result<{ res: Response, data: unknown }, Error>> {
185+
const result = await this.getPlain(route, headers)
186+
if (!result.ok) return result
187+
188+
const res = result.value
155189

156190
try {
157191
const data = await res.json()
158192
return ok({ res, data })
159193
} catch (e) {
160-
console.log("JSON parse failed.")
161194
return err(new Error("Failed to parse response as JSON.", { cause: e }))
162-
} finally {
163-
console.groupEnd()
164-
}
195+
}
165196
}
166197

167198
}
@@ -172,11 +203,17 @@ export default class WplaceAPI {
172203
export type APIOptions = {
173204
/** Base URL for the API */
174205
API_ROOT: string
206+
/** User-Agent used for HTTP requests */
207+
userAgent: string
208+
/** Default time in MS to wait before retrying request after 429 response */
209+
defaultRetryAfter: number
175210
}
176211

177212
/** Default options for the Wplace API client */
178213
const DEFAULT_API_OPTIONS: APIOptions = {
179-
API_ROOT: "https://backend.wplace.live"
214+
API_ROOT: "https://backend.wplace.live",
215+
userAgent: "wplace-api-client/0.1",
216+
defaultRetryAfter: 20000
180217
}
181218

182219
function compileOptions<OptionsType extends object>(options: Partial<OptionsType> | undefined, defaultOptions: OptionsType): OptionsType {
@@ -202,4 +239,8 @@ async function saveFile(path: string, data: Buffer) {
202239
} catch (e) {
203240
throw new Error(`Failed to write tile to ${path}.`, { cause: e })
204241
}
242+
}
243+
244+
function sleep(ms: number) {
245+
return new Promise(resolve => setTimeout(resolve, ms))
205246
}

src/result.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* Result type representing either a success (ok) or an error (err).
33
* @module
44
*/
5-
export type Result<T, E> = {
5+
export type Result<T = void, E = never> = {
66
ok: true
77
value: T
88
} | {

src/types.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,4 +92,6 @@ export const RandomPixel = type({
9292
* Endpoints:
9393
* - GET /s0/tile/random
9494
*/
95-
export type RandomPixel = typeof RandomPixel.infer
95+
export type RandomPixel = typeof RandomPixel.infer
96+
97+
export type HeaderMap = Record<string, string>

0 commit comments

Comments
 (0)