diff --git a/src/api/fetcher.ts b/src/api/fetcher.ts index 72fb17c..99bc1e9 100644 --- a/src/api/fetcher.ts +++ b/src/api/fetcher.ts @@ -141,19 +141,33 @@ export async function fetcher( } } - // Try to extract error message from JSON response + // Try to extract error message from response body try { - const contentType = response.headers.get('content-type') - if (contentType?.includes('application/json')) { + const contentType = response.headers.get('content-type') ?? '' + if (contentType.includes('application/json')) { const errorData = await response.json() if (errorData?.message) { errorMessage = errorData.message } else if (errorData?.err) { errorMessage = errorData.err } + } else if ( + contentType.includes('text/plain') || + contentType.includes('text/html') + ) { + // For text responses (e.g., 502 Bad Gateway), use the body text + const textBody = await response.text() + if (textBody) { + // Truncate long HTML/text responses to a reasonable length + const maxLength = 200 + errorMessage = + textBody.length > maxLength + ? `${textBody.slice(0, maxLength)}...` + : textBody + } } } catch { - // Ignore JSON parse errors, use default message + // Ignore parse errors, use default message } throw new FigmaApiError(errorMessage, statusCode, retryAfter) diff --git a/src/api/mutator.ts b/src/api/mutator.ts index e0a07e2..3836f76 100644 --- a/src/api/mutator.ts +++ b/src/api/mutator.ts @@ -153,15 +153,29 @@ export async function mutator( } } - // Try to extract error message from JSON response + // Try to extract error message from response body try { - const contentType = response.headers.get('content-type') - if (contentType?.includes('application/json')) { + const contentType = response.headers.get('content-type') ?? '' + if (contentType.includes('application/json')) { const errorData = await response.json() errorMessage = errorData.err || errorData.message || errorMessage + } else if ( + contentType.includes('text/plain') || + contentType.includes('text/html') + ) { + // For text responses (e.g., 502 Bad Gateway), use the body text + const textBody = await response.text() + if (textBody) { + // Truncate long HTML/text responses to a reasonable length + const maxLength = 200 + errorMessage = + textBody.length > maxLength + ? `${textBody.slice(0, maxLength)}...` + : textBody + } } } catch { - // Ignore JSON parse errors, use default message + // Ignore parse errors, use default message } throw new FigmaApiError(errorMessage, statusCode, retryAfter) diff --git a/tests/api/fetcher.test.ts b/tests/api/fetcher.test.ts index a64074f..ca23e8a 100644 --- a/tests/api/fetcher.test.ts +++ b/tests/api/fetcher.test.ts @@ -377,6 +377,118 @@ describe('fetcher', () => { ) }) + it('should extract error message from text/plain response', async () => { + const mockHeaders = new Headers() + mockHeaders.set('content-type', 'text/plain') + + mockFetch( + { + status: 503, + text: () => Promise.resolve('Service Unavailable'), + headers: mockHeaders, + }, + false + ) + + await expect(fetcher(DUMMY_URL, DUMMY_TOKEN)).rejects.toThrow( + 'Service Unavailable' + ) + }) + + it('should extract error message from text/html response', async () => { + const mockHeaders = new Headers() + mockHeaders.set('content-type', 'text/html') + + mockFetch( + { + status: 502, + text: () => Promise.resolve('Bad Gateway'), + headers: mockHeaders, + }, + false + ) + + await expect(fetcher(DUMMY_URL, DUMMY_TOKEN)).rejects.toThrow( + 'Bad Gateway' + ) + }) + + it('should truncate long text error responses', async () => { + const mockHeaders = new Headers() + mockHeaders.set('content-type', 'text/html') + + const longHtml = '' + 'x'.repeat(300) + '' + + mockFetch( + { + status: 502, + text: () => Promise.resolve(longHtml), + headers: mockHeaders, + }, + false + ) + + try { + await fetcher(DUMMY_URL, DUMMY_TOKEN) + } catch (err) { + expect((err as Error).message).toHaveLength(203) // 200 chars + "..." + expect((err as Error).message.endsWith('...')).toBe(true) + } + }) + + it('should use default message when text body is empty', async () => { + const mockHeaders = new Headers() + mockHeaders.set('content-type', 'text/plain') + + mockFetch( + { + status: 503, + text: () => Promise.resolve(''), + headers: mockHeaders, + }, + false + ) + + await expect(fetcher(DUMMY_URL, DUMMY_TOKEN)).rejects.toThrow( + 'An error occurred while fetching data from the Figma API' + ) + }) + + it('should use default message when content-type is null', async () => { + const mockHeaders = new Headers() + // No content-type set + + mockFetch( + { + status: 500, + headers: mockHeaders, + }, + false + ) + + await expect(fetcher(DUMMY_URL, DUMMY_TOKEN)).rejects.toThrow( + 'An error occurred while fetching data from the Figma API' + ) + }) + + it('should use default message when text() throws', async () => { + const mockHeaders = new Headers() + mockHeaders.set('content-type', 'text/plain') + + mockFetch( + { + status: 503, + text: () => Promise.reject(new Error('Read error')), + headers: mockHeaders, + }, + false + ) + + await expect(fetcher(DUMMY_URL, DUMMY_TOKEN)).rejects.toThrow( + 'An error occurred while fetching data from the Figma API' + ) + }) + it('should not parse Retry-After for non-429 errors', async () => { const mockHeaders = new Headers() mockHeaders.set('content-type', 'application/json') diff --git a/tests/api/mutator.test.ts b/tests/api/mutator.test.ts index 61e0eaf..83f71ce 100644 --- a/tests/api/mutator.test.ts +++ b/tests/api/mutator.test.ts @@ -173,6 +173,93 @@ describe('mutator', () => { ) }) + it('should extract error message from text/plain response', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 503, + headers: { + get: (name: string) => (name === 'content-type' ? 'text/plain' : null), + }, + text: () => Promise.resolve('Service Unavailable'), + }) + await expect(mutator(url, token, 'CREATE', body)).rejects.toThrow( + 'Service Unavailable' + ) + }) + + it('should extract error message from text/html response', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 502, + headers: { + get: (name: string) => (name === 'content-type' ? 'text/html' : null), + }, + text: () => Promise.resolve('Bad Gateway'), + }) + await expect(mutator(url, token, 'CREATE', body)).rejects.toThrow( + 'Bad Gateway' + ) + }) + + it('should truncate long text error responses', async () => { + const longHtml = '' + 'x'.repeat(300) + '' + mockFetch.mockResolvedValue({ + ok: false, + status: 502, + headers: { + get: (name: string) => (name === 'content-type' ? 'text/html' : null), + }, + text: () => Promise.resolve(longHtml), + }) + try { + await mutator(url, token, 'CREATE', body) + } catch (err) { + expect((err as Error).message).toHaveLength(203) // 200 chars + "..." + expect((err as Error).message.endsWith('...')).toBe(true) + } + }) + + it('should use default message when text body is empty', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 503, + headers: { + get: (name: string) => (name === 'content-type' ? 'text/plain' : null), + }, + text: () => Promise.resolve(''), + }) + await expect(mutator(url, token, 'CREATE', body)).rejects.toThrow( + 'An API error occurred' + ) + }) + + it('should use default message when content-type is null', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 500, + headers: { + get: () => null, + }, + }) + await expect(mutator(url, token, 'CREATE', body)).rejects.toThrow( + 'An API error occurred' + ) + }) + + it('should use default message when text() throws', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 503, + headers: { + get: (name: string) => (name === 'content-type' ? 'text/plain' : null), + }, + text: () => Promise.reject(new Error('Read error')), + }) + await expect(mutator(url, token, 'CREATE', body)).rejects.toThrow( + 'An API error occurred' + ) + }) + it('should use default errorMessage when both err and message are falsy', async () => { mockFetch.mockResolvedValue({ ok: false,