Исправление: Unhandled Exception: FormatException: Unexpected character при разборе JSON в Dart
Решение за 30 секунд: тело ответа не тот JSON, который вы думаете. Выведите сырые байты, декодируйте через utf8.decode(response.bodyBytes) и никогда не передавайте HTML-страницу с ошибкой или строку с BOM в jsonDecode.
Решение в одной фразе: jsonDecode упал не на вашем JSON. Он упал на чём-то, что не является JSON, в теле, которое вы передали. В девяти случаях из десяти тело — это HTML-страница с ошибкой из вашего API, UTF-8 BOM перед валидным JSON, HTTP-ответ, декодированный с неправильным charset, или null либо пустая строка из запроса, который сервер уже отклонил. Выведите первые 80 байт тела, декодируйте байты через utf8.decode(response.bodyBytes) вместо того, чтобы позволить package:http угадывать charset, и добавьте проверку статус-кода перед вызовом jsonDecode.
Unhandled Exception: FormatException: Unexpected character (at character 1)
<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>Error</title>...
^
#0 _ChunkedJsonParser.fail (dart:convert-patch/convert_patch.dart:1452:5)
#1 _ChunkedJsonParser.parseNumber (dart:convert-patch/convert_patch.dart:1319:9)
#2 _ChunkedJsonParser.parse (dart:convert-patch/convert_patch.dart:907:22)
#3 _parseJson (dart:convert-patch/convert_patch.dart:41:10)
#4 JsonDecoder.convert (dart:convert/json.dart:613:36)
#5 JsonCodec.decode (dart:convert/json.dart:175:41)
#6 jsonDecode (dart:convert/json.dart:96:10)
Это руководство написано против Dart 3.7 (стабильный канал на май 2026), Flutter 3.27 и package:http 1.5.0. Описанное здесь поведение не менялось с Dart 2.12 и появления null safety. API jsonDecode находится в dart:convert; парсер, который бросает исключение, — это _ChunkedJsonParser в SDK в sdk/lib/_internal/vm/lib/convert_patch.dart.
Почему эту ошибку почти всегда диагностируют неправильно
FormatException: Unexpected character из dart:convert — точное сообщение. Парсер нашёл байт, который не смог сопоставить ни с одним валидным JSON-токеном на конкретном смещении, и бросил исключение с этим смещением в поле offset объекта FormatException. Смещение — единственное число, которое имеет значение в сообщении, и большинство разработчиков его игнорируют.
at character 0илиat character 1: самый первый байт неверен. Либо вы смотрите на<!DOCTYPE html>(страница ошибки), либо на UTF-8 BOM (0xEF 0xBB 0xBF), либо на пустую строку, либо на JSON-конверт в кавычках (""{…}"").at character 2—at character 5в теле, которое выглядит нормально в отладчике: несоответствие кодировок.package:httpдекодировал байты как Latin-1, потому что сервер не прислалcharset=utf-8вContent-Type, и первый не-ASCII байт всё сломал.at character Nс N глубоко в середине тела: реально некорректный JSON от сервера или строка, склеенная с мусором (очень часто бывает с логирующими прокси, которые приписывают баннер впереди).
Невозможно диагностировать это, не глядя на байты. Обернуть jsonDecode в try / catch правильно как финальный слой, но это скрывает реальную причину: вы пропускаете в парсер невалидированный ввод.
Минимальный repro, который можно вставить в файл Dart
// Dart 3.7, package:http 1.5.0
import 'dart:convert';
import 'package:http/http.dart' as http;
Future<Map<String, dynamic>> fetchUser(String id) async {
final response = await http.get(Uri.parse('https://api.example.com/users/$id'));
return jsonDecode(response.body) as Map<String, dynamic>;
}
Это форма кода, который бросает ошибку в 90% реальных багрепортов. В этих трёх строках спрятаны четыре независимых режима отказа:
- Сервер вернул
404с HTML-телом.jsonDecode("<!DOCTYPE...")падает на символе 0. - Сервер вернул
200, но сContent-Type: application/json; charset=utf-8и BOM в начале.jsonDecode("{...}")падает на символе 0. - Сервер вернул
200сContent-Type: application/json(без charset).package:httpоткатывается наlatin-1, имя пользователя с диакритикой превращается в моджибаке, и JSON-декодер всё ещё работает, пока в ваших данных не появится последовательность байт, похожая на управляющий символ, и тогда он падает. - Сервер вернул пустое тело на
204 No Content, но место вызова обработало его как JSON.
Тот же тип исключения, четыре корневые причины, четыре разных решения.
Решение, в порядке частоты применимости
1. Выведите тело перед декодированием
Это не опционально. Прежде чем менять любой код, выведите первые 200 символов и длину:
// Dart 3.7
import 'dart:convert';
import 'package:http/http.dart' as http;
final response = await http.get(uri);
final preview = response.body.length > 200
? '${response.body.substring(0, 200)}...'
: response.body;
print('status=${response.statusCode} '
'len=${response.bodyBytes.length} '
'content-type=${response.headers['content-type']}');
print('body[0..200]=$preview');
print('first-bytes=${response.bodyBytes.take(8).toList()}');
Первые восемь байт говорят всё. [60, 33, 68, 79, 67, 84, 89, 80] — это <!DOCTYP, и вы смотрите на HTML-страницу с ошибкой. [239, 187, 191, 123, ...] начинается с UTF-8 BOM. [123, 34, ...] ({") — это настоящий JSON. Выведите это один раз — и вы поймёте, какое из решений ниже применять.
2. Проверьте статус-код перед декодированием
// Dart 3.7, package:http 1.5.0
final response = await http.get(uri);
if (response.statusCode != 200) {
throw HttpException(
'GET $uri returned ${response.statusCode}: ${response.body}',
);
}
if (response.bodyBytes.isEmpty) {
throw const FormatException('Empty body where JSON was expected');
}
final data = jsonDecode(utf8.decode(response.bodyBytes)) as Map<String, dynamic>;
Это покрывает случаи 1 и 4 из списка repro. 404 с HTML-телом никогда не доходит до jsonDecode, а 204 без тела бросает более понятную ошибку, указывающую на настоящую проблему.
3. Декодируйте байты через utf8.decode, а не через response.body
response.body в package:http прогоняет байты через тот charset, который объявил сервер, и откатывается на latin-1, когда не объявлено ничего. Этот fallback — источник большинства багов вида “JSON выглядел нормально в Postman, но моё приложение падает”. Всегда используйте utf8.decode(response.bodyBytes), если знаете, что API говорит на JSON:
// Dart 3.7
final text = utf8.decode(response.bodyBytes);
final data = jsonDecode(text) as Map<String, dynamic>;
Для API, действительно меняющих кодировку, разбирайте заголовок Content-Type и выбирайте нужный кодек, но правильный дефолт для JSON по HTTP — это UTF-8: RFC 8259, раздел 8.1 этого требует. Геттер body существует потому, что спецификация HTTP позволяет другие кодировки; JSON — нет.
4. Уберите BOM, если ваш сервер его шлёт
BOM в начале UTF-8-потока легален по спецификации Unicode и запрещён RFC 8259, раздел 8.1, поэтому JSON-парсеры правы, отклоняя его. Некоторые конфигурации IIS и часть edge-функций CDN до сих пор приписывают 0xEF 0xBB 0xBF к JSON-ответам. Решение в одну строку:
// Dart 3.7
String stripBom(String s) =>
s.startsWith('') ? s.substring(1) : s;
final text = stripBom(utf8.decode(response.bodyBytes));
final data = jsonDecode(text);
Или используйте utf8.decoder с allowMalformed: false прямо на байтах после среза BOM:
// Dart 3.7
final bytes = response.bodyBytes;
final start = (bytes.length >= 3 &&
bytes[0] == 0xEF &&
bytes[1] == 0xBB &&
bytes[2] == 0xBF)
? 3
: 0;
final data = jsonDecode(utf8.decode(bytes.sublist(start)));
Байтовый вариант быстрее на больших payload, потому что пропускает шаг выделения строки.
5. Прекратите двойное кодирование на сервере
Другая частая форма этой ошибки — смещение глубоко внутри строки, которая выглядит валидной:
Unhandled Exception: FormatException: Unexpected character (at character 2)
"{\"id\":42,\"name\":\"Ana\"}"
^
Тело — это JSON-строка, содержимое которой — тоже JSON. Где-то на сервере JSON-объект сериализовали в строку, а потом строку сериализовали ещё раз (JSON.stringify(JSON.stringify(obj)) в Node Express-обработчике или возврат Json(json) вместо объекта в ASP.NET Core). Форма решения в Dart правильная:
// Dart 3.7
final outer = jsonDecode(text);
final data = outer is String ? jsonDecode(outer) : outer;
Но оставьте TODO исправить сервер. Дважды закодированный JSON ломает каждого клиента, а не только Dart, и следующий вызвавший этот endpoint заплатит тем же временем на отладку.
Как читать offset и превью source
FormatException, которую бросает Dart, несёт три полезных свойства: message, source и offset. Когда ловите её, логируйте все три:
// Dart 3.7
try {
final data = jsonDecode(text);
} on FormatException catch (e) {
print('JSON parse failed at offset ${e.offset}.');
if (e.source is String) {
final src = e.source as String;
final start = (e.offset ?? 0) - 20;
final end = (e.offset ?? 0) + 20;
final window = src.substring(start.clamp(0, src.length), end.clamp(0, src.length));
print('context: "$window"');
}
rethrow;
}
Это правильный уровень логирования в любом приложении, потребляющем внешний JSON. Дефолтный toString() обрезает source примерно до 78 символов с ^ под проблемным байтом — обычно достаточно на маленьких payload и бесполезно на ответе в 200 КБ.
Подводные камни, которые выглядят как JSON-баг, но им не являются
response.body возвращает String, даже если тело бинарное
Геттер body в package:http вызывает latin1.decode, когда charset не задан, и затем ваш код вызывает jsonDecode на строке, которая не есть то, что пришло по сети. Если действительно нужен сырой текст, используйте response.bodyBytes и декодируйте сами. То же касается package:dio: настраивайте ResponseType.bytes или ResponseType.plain, если хотите контролировать декодирование.
compute(jsonDecode, ...) глотает offset
В Flutter часто можно увидеть final data = await compute(jsonDecode, text);, чтобы вытолкнуть парсинг в isolate. Когда парсинг падает, FormatException перебрасывается через границу isolate, и поле source теряется. Вы увидите сообщение, но не сниппет. Сначала декодируйте на основном isolate, чтобы понять, что не так, затем переключайтесь обратно на compute, когда вход чист.
Nullable String?, декодированный через !, скрывает пустой случай
final body = response.body;
final data = jsonDecode(body!); // unsafe
Если сервер вернул 204 No Content, body равно '', и jsonDecode('') бросает Unexpected end of input. Используйте явную проверку isEmpty; не полагайтесь только на null safety.
json.decoder.bind(stream) сообщает смещения относительно чанка
Если вы парсите стримовый ответ через response.stream.transform(utf8.decoder).transform(json.decoder), смещение в результирующем FormatException локально к чанку, который упал, а не ко всему телу. Issue Dart dart-lang/sdk#56264 отслеживает шероховатости здесь. Для стримового JSON переключайтесь на package:json_stream_parser или буферизуйте тело перед декодированием.
Flutter web в Firefox сообщает об ошибке иначе
В Flutter web вызывается базовый JS JSON.parse. Firefox бросает SyntaxError: JSON.parse: unexpected character, тогда как Chrome бросает SyntaxError: Unexpected token, и Flutter оборачивает оба в FormatException. Решение такое же, как в этом руководстве, но если вы отлаживаете отказы только в web, сначала воспроизведите в консоли devtools браузера с сырой строкой ответа, чтобы увидеть нативную ошибку браузера.
Связанные
- Исправление: TaskCanceledException: A task was canceled в HttpClient — это .NET-родственник проблемы “ваш HTTP-слой обманывает ваш парсер”. Применима та же диагностическая дисциплина.
- Исправление: The JSON value could not be converted to System.DateTime покрывает случай, когда тело действительно JSON, но форма неверна.
- Исправление: A RenderFlex overflowed by N pixels в Flutter — это другая ошибка Flutter, которую новички диагностируют неправильно; та же дисциплина “сначала вывести реальное состояние”.
- Как написать Dart isolate для CPU-задач становится актуальной, когда ваш JSON достаточно велик, чтобы оправдать
compute, после того как вы уже почистили вход.
Источники
- Справочник библиотеки dart:convert, документирующий
jsonDecode,utf8.decodeи контрактJsonDecoder. - Справочник класса FormatException, API-документация, определяющая
message,sourceиoffset. - RFC 8259, раздел 8.1, текст спецификации JSON, требующий UTF-8 по сети и запрещающий BOM.
- Обработка ответа в package:http, где описан fallback charset, удивляющий большинство вызывающих.
- dart-lang/sdk#46959, каноническая issue с примерами того же исключения, брошенного на разных реальных входах.
- dart-lang/sdk#56264, отслеживающая поведение offset чанкового декодера, упомянутое выше.
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.