{"id":51739569,"url":"https://github.com/owlnext-fr/lucky","last_synced_at":"2026-07-18T11:40:35.536Z","repository":{"id":339378194,"uuid":"1161568517","full_name":"owlnext-fr/lucky","owner":"owlnext-fr","description":"Build structured, maintainable API clients in Dart/Flutter — no code generation required.","archived":false,"fork":false,"pushed_at":"2026-06-10T07:11:30.000Z","size":10002,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-07-18T11:40:14.643Z","etag":null,"topics":["api","dart","flutter","http","rest-api"],"latest_commit_sha":null,"homepage":"","language":"Dart","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/owlnext-fr.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-02-19T09:05:00.000Z","updated_at":"2026-06-10T07:11:34.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/owlnext-fr/lucky","commit_stats":null,"previous_names":["owlnext-fr/lucky"],"tags_count":3,"template":false,"template_full_name":null,"purl":"pkg:github/owlnext-fr/lucky","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/owlnext-fr%2Flucky","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/owlnext-fr%2Flucky/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/owlnext-fr%2Flucky/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/owlnext-fr%2Flucky/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/owlnext-fr","download_url":"https://codeload.github.com/owlnext-fr/lucky/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/owlnext-fr%2Flucky/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":35616737,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-07-18T02:00:07.223Z","response_time":61,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["api","dart","flutter","http","rest-api"],"created_at":"2026-07-18T11:40:34.886Z","updated_at":"2026-07-18T11:40:35.530Z","avatar_url":"https://github.com/owlnext-fr.png","language":"Dart","funding_links":[],"categories":[],"sub_categories":[],"readme":"\u003ch1 align=\"center\"\u003e🤠\u003c/h1\u003e\n\u003ch1 align=\"center\"\u003eLucky\u003c/h1\u003e\n\n\u003cp align=\"center\"\u003e\n  \u003cstrong\u003eBuild structured, maintainable API clients in Dart/Flutter — no code generation required.\u003c/strong\u003e\n\u003c/p\u003e\n\n\u003cp align=\"center\"\u003e\n  \u003ca href=\"https://pub.dev/packages/lucky_dart\"\u003e\u003cimg src=\"https://img.shields.io/pub/v/lucky_dart.svg?label=pub.dev\u0026color=orange\" alt=\"pub version\"\u003e\u003c/a\u003e\n  \u003ca href=\"https://pub.dev/packages/lucky_dart/score\"\u003e\u003cimg src=\"https://img.shields.io/pub/points/lucky_dart?label=pub%20points\u0026color=brightgreen\" alt=\"pub points\"\u003e\u003c/a\u003e\n  \u003ca href=\"https://pub.dev/packages/lucky_dart/score\"\u003e\u003cimg src=\"https://img.shields.io/pub/likes/lucky_dart?label=likes\u0026color=pink\" alt=\"pub likes\"\u003e\u003c/a\u003e\n  \u003ca href=\"https://dart.dev\"\u003e\u003cimg src=\"https://img.shields.io/badge/dart-%3E%3D3.0.0-00B4AB?logo=dart\u0026logoColor=white\" alt=\"Dart SDK\"\u003e\u003c/a\u003e\n  \u003ca href=\"https://flutter.dev\"\u003e\u003cimg src=\"https://img.shields.io/badge/flutter-compatible-02569B?logo=flutter\u0026logoColor=white\" alt=\"Flutter compatible\"\u003e\u003c/a\u003e\n  \u003ca href=\"LICENSE\"\u003e\u003cimg src=\"https://img.shields.io/badge/license-MIT-blue.svg\" alt=\"License: MIT\"\u003e\u003c/a\u003e\n\u003c/p\u003e\n\n---\n\n\nLucky gives you a clean, object-oriented way to organise all your API calls. Instead of scattering `http.get(...)` calls across your codebase, you define one **Connector** per API and one **Request** class per endpoint. Every call is typed, testable, and consistent.\n\n```dart\nfinal api = ForgeConnector(token: myToken);\nfinal servers = await api.send(GetServersRequest());\nprint(servers.jsonList());\n```\n\n---\n\n## Table of Contents\n\n- [Table of Contents](#table-of-contents)\n- [Installation](#installation)\n- [Core concepts](#core-concepts)\n- [Quick start](#quick-start)\n- [Connector](#connector)\n- [Request](#request)\n- [Endpoint pattern](#endpoint-pattern)\n  - [Defining endpoints](#defining-endpoints)\n  - [Wiring into the Connector](#wiring-into-the-connector)\n  - [Usage](#usage)\n- [Body mixins](#body-mixins)\n  - [JSON (`HasJsonBody`)](#json-hasjsonbody)\n  - [Form URL-encoded (`HasFormBody`)](#form-url-encoded-hasformbody)\n  - [Multipart / file upload (`HasMultipartBody`)](#multipart--file-upload-hasmultipartbody)\n  - [XML (`HasXmlBody`)](#xml-hasxmlbody)\n  - [Plain text (`HasTextBody`)](#plain-text-hastextbody)\n  - [Binary stream (`HasStreamBody`)](#binary-stream-hasstreambody)\n- [Authentication](#authentication)\n  - [Bearer token](#bearer-token)\n  - [Basic auth](#basic-auth)\n  - [API key in query param](#api-key-in-query-param)\n  - [Custom header](#custom-header)\n  - [Runtime auth (set after login)](#runtime-auth-set-after-login)\n  - [Disable auth per request](#disable-auth-per-request)\n- [Response helpers](#response-helpers)\n  - [Parsing into a model with `as()`](#parsing-into-a-model-with-as)\n- [Error handling](#error-handling)\n  - [Parse errors](#parse-errors)\n- [Retry](#retry)\n  - [How retry works](#how-retry-works)\n  - [ExponentialBackoffRetryPolicy](#exponentialbackoffretrrypolicy)\n  - [LinearBackoffRetryPolicy](#linearbackoffretrrypolicy)\n  - [ImmediateRetryPolicy](#immediateretrrypolicy)\n  - [Jitter](#jitter)\n  - [Custom RetryPolicy](#custom-retrypolicy)\n- [Throttle](#throttle)\n  - [How throttle works](#how-throttle-works)\n  - [RateLimitThrottlePolicy](#ratelimitthrottlepolicy)\n  - [TokenBucketThrottlePolicy](#tokenbucketthrottlepolicy)\n  - [ConcurrencyThrottlePolicy](#concurrencythrottlepolicy)\n- [Combining retry and throttle](#combining-retry-and-throttle)\n- [Logging \\\u0026 debug](#logging--debug)\n- [Custom interceptors](#custom-interceptors)\n- [Why Lucky?](#why-lucky)\n\n---\n\n## Installation\n\n```yaml\ndependencies:\n  lucky_dart: ^1.0.0\n```\n\n```bash\ndart pub get\n```\n\n---\n\n## Core concepts\n\n| Concept           | Role                                                               |\n| ----------------- | ------------------------------------------------------------------ |\n| **Connector**     | One per API — holds base URL, default headers, auth, Dio singleton |\n| **Request**       | One per endpoint — defines method, path, body, query params        |\n| **LuckyResponse** | Wraps `dio.Response` with status helpers and parsing shortcuts     |\n| **Authenticator** | Pluggable auth strategy applied automatically to every request     |\n| **Body mixin**    | Adds `Content-Type` and `body()` to a Request in one line          |\n\n---\n\n## Quick start\n\n```dart\nimport 'package:lucky_dart/lucky_dart.dart';\n\n// 1. Define the connector\nclass ForgeConnector extends Connector {\n  final String _token;\n  ForgeConnector({required String token}) : _token = token;\n\n  @override\n  String resolveBaseUrl() =\u003e 'https://forge.laravel.com/api/v1';\n\n  @override\n  Authenticator? get authenticator =\u003e TokenAuthenticator(_token);\n}\n\n// 2. Define a request\nclass GetServersRequest extends Request {\n  @override String get method =\u003e 'GET';\n  @override String resolveEndpoint() =\u003e '/servers';\n}\n\n// 3. Send it\nvoid main() async {\n  final forge = ForgeConnector(token: 'my-api-token');\n  final response = await forge.send(GetServersRequest());\n\n  for (final server in response.jsonList()) {\n    print(server['name']);\n  }\n}\n```\n\n---\n\n## Connector\n\nThe `Connector` is the entry point for an entire API. Subclass it once per third-party service.\n\n```dart\nclass GithubConnector extends Connector {\n  final String _token;\n  GithubConnector(this._token);\n\n  @override\n  String resolveBaseUrl() =\u003e 'https://api.github.com';\n\n  @override\n  Map\u003cString, String\u003e? defaultHeaders() =\u003e {\n    'Accept': 'application/vnd.github+json',\n    'X-GitHub-Api-Version': '2022-11-28',\n  };\n\n  @override\n  Authenticator? get authenticator =\u003e TokenAuthenticator(_token);\n\n  // Disable exceptions for non-2xx — handle status codes manually\n  @override\n  bool get throwOnError =\u003e false;\n}\n```\n\n**Available overrides:**\n\n| Getter / Method    | Default | Description                                  |\n| ------------------ | ------- | -------------------------------------------- |\n| `resolveBaseUrl()` | —       | **Required.** Base URL for all requests.     |\n| `defaultHeaders()` | `null`  | Headers merged into every request.           |\n| `defaultQuery()`   | `null`  | Query params merged into every request.      |\n| `defaultOptions()` | `null`  | Dio `Options` merged into every request.     |\n| `authenticator`    | `null`  | Auth strategy applied to every request.      |\n| `useAuth`          | `true`  | Enable/disable auth at the connector level.  |\n| `throwOnError`     | `true`  | Throw typed exceptions for 4xx/5xx.          |\n| `enableLogging`    | `false` | Enable the logging interceptor.              |\n| `onLog`            | `null`  | Logging callback (wired to your own logger). |\n| `debugMode`        | `false` | Enable the debug interceptor.                |\n| `onDebug`          | `null`  | Debug callback.                              |\n| `interceptors`     | `[]`    | Additional Dio interceptors.                 |\n\n---\n\n## Request\n\nEach API endpoint gets its own `Request` subclass.\n\n```dart\nclass GetRepositoryRequest extends Request {\n  final String owner;\n  final String repo;\n\n  GetRepositoryRequest(this.owner, this.repo);\n\n  @override\n  String get method =\u003e 'GET';\n\n  @override\n  String resolveEndpoint() =\u003e '/repos/$owner/$repo';\n\n  @override\n  Map\u003cString, dynamic\u003e? queryParameters() =\u003e {'per_page': 100};\n\n  @override\n  Map\u003cString, String\u003e? headers() =\u003e {'X-Custom': 'value'};\n}\n```\n\n**Available overrides:**\n\n| Getter / Method     | Default | Description                                                            |\n| ------------------- | ------- | ---------------------------------------------------------------------- |\n| `method`            | —       | **Required.** HTTP verb (`GET`, `POST`, etc.)                          |\n| `resolveEndpoint()` | —       | **Required.** Path relative to base URL.                               |\n| `headers()`         | `null`  | Extra headers (merged on top of connector defaults).                   |\n| `queryParameters()` | `null`  | Extra query params (merged on top of connector defaults).              |\n| `body()`            | `null`  | Request body. Usually set by a body mixin.                             |\n| `buildOptions()`    | —       | Dio `Options`. Body mixins enrich this automatically.                  |\n| `useAuth`           | `null`  | Per-request auth override (`null`=inherit, `false`=off, `true`=force). |\n| `logRequest`        | `true`  | Include this request in logs. Set `false` for sensitive requests.      |\n| `logResponse`       | `true`  | Include the response in logs.                                          |\n\n---\n\n## Endpoint pattern\n\nFor large APIs, group related requests into **endpoint classes**. This gives you a clean, namespace-based API:\n\n```dart\nconnector.users.list()\nconnector.users.get(42)\nconnector.repositories.create(name: 'my-repo')\n```\n\n### Defining endpoints\n\n```dart\n// Requests\nclass ListUsersRequest extends Request {\n  @override String get method =\u003e 'GET';\n  @override String resolveEndpoint() =\u003e '/users';\n}\n\nclass GetUserRequest extends Request {\n  final int id;\n  GetUserRequest(this.id);\n  @override String get method =\u003e 'GET';\n  @override String resolveEndpoint() =\u003e '/users/$id';\n}\n\nclass CreateUserRequest extends Request with HasJsonBody {\n  final String name;\n  final String email;\n  CreateUserRequest({required this.name, required this.email});\n  @override String get method =\u003e 'POST';\n  @override String resolveEndpoint() =\u003e '/users';\n  @override Map\u003cString, dynamic\u003e jsonBody() =\u003e {'name': name, 'email': email};\n}\n\n// Endpoint class\nclass UsersEndpoint {\n  final Connector _connector;\n  UsersEndpoint(this._connector);\n\n  Future\u003cLuckyResponse\u003e list() =\u003e\n      _connector.send(ListUsersRequest());\n\n  Future\u003cLuckyResponse\u003e get(int id) =\u003e\n      _connector.send(GetUserRequest(id));\n\n  Future\u003cLuckyResponse\u003e create({required String name, required String email}) =\u003e\n      _connector.send(CreateUserRequest(name: name, email: email));\n}\n```\n\n### Wiring into the Connector\n\n```dart\nclass ApiConnector extends Connector {\n  ApiConnector({required String token}) : _token = token;\n  final String _token;\n\n  @override\n  String resolveBaseUrl() =\u003e 'https://api.example.com';\n\n  @override\n  Authenticator? get authenticator =\u003e TokenAuthenticator(_token);\n\n  // Endpoint accessors\n  late final users = UsersEndpoint(this);\n}\n```\n\n### Usage\n\n```dart\nfinal api = ApiConnector(token: 'my-token');\n\n// List users\nfinal users = await api.users.list();\nprint(users.jsonList());\n\n// Get a specific user\nfinal user = await api.users.get(42);\nprint(user.json()['name']);\n\n// Create a user\nfinal created = await api.users.create(name: 'Alice', email: 'alice@example.com');\nprint(created.json()['id']);\n```\n\n---\n\n## Body mixins\n\nAdd a body mixin to your Request and implement one method. Lucky sets `Content-Type` automatically.\n\n### JSON (`HasJsonBody`)\n\n```dart\nclass CreatePostRequest extends Request with HasJsonBody {\n  final String title;\n  final String content; // note: don't name this 'body' — conflicts with body() from HasJsonBody\n\n  CreatePostRequest({required this.title, required this.content});\n\n  @override String get method =\u003e 'POST';\n  @override String resolveEndpoint() =\u003e '/posts';\n\n  @override\n  Map\u003cString, dynamic\u003e jsonBody() =\u003e {'title': title, 'body': content};\n}\n```\n\nSets `Content-Type: application/json` and `Accept: application/json`.\n\n### Form URL-encoded (`HasFormBody`)\n\n```dart\nclass LoginRequest extends Request with HasFormBody {\n  final String email;\n  final String password;\n\n  LoginRequest(this.email, this.password);\n\n  @override String get method =\u003e 'POST';\n  @override String resolveEndpoint() =\u003e '/login';\n  @override bool? get useAuth =\u003e false; // skip auth on login\n  @override bool get logRequest =\u003e false; // don't log credentials\n\n  @override\n  Map\u003cString, dynamic\u003e formBody() =\u003e {'email': email, 'password': password};\n}\n```\n\nSets `Content-Type: application/x-www-form-urlencoded`.\n\n### Multipart / file upload (`HasMultipartBody`)\n\n```dart\nclass UploadAvatarRequest extends Request with HasMultipartBody {\n  final File file;\n  final String userId;\n\n  UploadAvatarRequest({required this.file, required this.userId});\n\n  @override String get method =\u003e 'POST';\n  @override String resolveEndpoint() =\u003e '/users/$userId/avatar';\n\n  @override\n  Future\u003cFormData\u003e multipartBody() async =\u003e FormData.fromMap({\n    'avatar': await MultipartFile.fromFile(file.path, filename: 'avatar.jpg'),\n  });\n}\n```\n\nSets `Content-Type: multipart/form-data`.\n\n### XML (`HasXmlBody`)\n\n```dart\nclass SubmitOrderRequest extends Request with HasXmlBody {\n  final String orderId;\n\n  SubmitOrderRequest(this.orderId);\n\n  @override String get method =\u003e 'POST';\n  @override String resolveEndpoint() =\u003e '/orders';\n\n  @override\n  String xmlBody() =\u003e '''\u003c?xml version=\"1.0\" encoding=\"UTF-8\"?\u003e\n\u003corder\u003e\u003cid\u003e$orderId\u003c/id\u003e\u003c/order\u003e''';\n}\n```\n\nSets `Content-Type: application/xml` and `Accept: application/xml`.\n\n### Plain text (`HasTextBody`)\n\n```dart\nclass SendRawRequest extends Request with HasTextBody {\n  final String content;\n  SendRawRequest(this.content);\n\n  @override String get method =\u003e 'POST';\n  @override String resolveEndpoint() =\u003e '/raw';\n\n  @override\n  String textBody() =\u003e content;\n}\n```\n\nSets `Content-Type: text/plain`.\n\n### Binary stream (`HasStreamBody`)\n\n```dart\nclass UploadFileRequest extends Request with HasStreamBody {\n  final File file;\n  UploadFileRequest(this.file);\n\n  @override String get method =\u003e 'PUT';\n  @override String resolveEndpoint() =\u003e '/upload';\n\n  @override\n  int get contentLength =\u003e file.lengthSync();\n\n  @override\n  Stream\u003cList\u003cint\u003e\u003e streamBody() =\u003e file.openRead();\n}\n```\n\nSets `Content-Type: application/octet-stream` and `Content-Length`.\n\n---\n\n## Authentication\n\n### Bearer token\n\n```dart\nclass MyConnector extends Connector {\n  @override\n  Authenticator? get authenticator =\u003e TokenAuthenticator('my-token');\n  // Adds: Authorization: Bearer my-token\n}\n\n// Custom prefix\nTokenAuthenticator('my-token', prefix: 'Token')\n// Adds: Authorization: Token my-token\n```\n\n### Basic auth\n\n```dart\nclass MyConnector extends Connector {\n  @override\n  Authenticator? get authenticator =\u003e BasicAuthenticator('user', 'password');\n  // Adds: Authorization: Basic dXNlcjpwYXNzd29yZA==\n}\n```\n\n### API key in query param\n\n```dart\nclass WeatherConnector extends Connector {\n  final _auth = QueryAuthenticator('appid', 'my-api-key');\n\n  @override\n  Map\u003cString, dynamic\u003e? defaultQuery() =\u003e _auth.toQueryMap();\n  // Appends: ?appid=my-api-key to every request\n}\n```\n\n`QueryAuthenticator` uses `toQueryMap()` in `defaultQuery()`, not `apply()`, because query params live outside Dio `Options`.\n\n### Custom header\n\n```dart\nclass MyConnector extends Connector {\n  @override\n  Authenticator? get authenticator =\u003e HeaderAuthenticator('X-Api-Key', 'secret');\n  // Adds: X-Api-Key: secret\n}\n```\n\n### Runtime auth (set after login)\n\n`authenticator` is a getter re-evaluated on every `send()` call, so you can change it at runtime:\n\n```dart\nclass ApiConnector extends Connector {\n  Authenticator? _auth;\n\n  // Public setter so callers in other files can update auth at runtime\n  set authenticatorOverride(Authenticator? auth) =\u003e _auth = auth;\n\n  @override\n  Authenticator? get authenticator =\u003e _auth;\n\n  @override\n  String resolveBaseUrl() =\u003e 'https://api.example.com';\n}\n\n// Usage\nfinal api = ApiConnector();\n\n// No auth yet — login endpoint skips it\nfinal login = await api.send(LoginRequest(email: 'user@example.com', password: 'secret'));\nfinal token = login.json()['token'] as String;\n\n// Set token for all subsequent requests via the public setter\napi.authenticatorOverride = TokenAuthenticator(token);\n\nfinal profile = await api.send(GetProfileRequest()); // now authenticated\n```\n\n### Disable auth per request\n\nOverride `useAuth` on any `Request` to opt out of authentication:\n\n```dart\nclass LoginRequest extends Request with HasFormBody {\n  @override String get method =\u003e 'POST';\n  @override String resolveEndpoint() =\u003e '/login';\n  @override bool? get useAuth =\u003e false; // skip auth for this endpoint\n\n  @override\n  Map\u003cString, dynamic\u003e formBody() =\u003e {'email': email, 'password': password};\n}\n```\n\n**`Request.useAuth` values:**\n\n| Value            | Effect                                             |\n| ---------------- | -------------------------------------------------- |\n| `null` (default) | Inherits `Connector.useAuth`                       |\n| `false`          | Disables auth for this request                     |\n| `true`           | Forces auth even if `Connector.useAuth` is `false` |\n\n---\n\n## Response helpers\n\n```dart\nfinal r = await connector.send(GetUsersRequest());\n\n// Status checks\nr.isSuccessful   // 200-299\nr.isClientError  // 400-499\nr.isServerError  // 500+\nr.isRedirect     // 300-399\nr.statusCode     // int\n\n// Content type\nr.isJson   // Content-Type contains application/json\nr.isXml    // Content-Type contains xml\nr.isHtml   // Content-Type contains text/html\n\n// Parsing — throw LuckyParseException on type mismatch\nr.json()       // Map\u003cString, dynamic\u003e\nr.jsonList()   // List\u003cdynamic\u003e\nr.text()       // String\nr.bytes()      // List\u003cint\u003e\n\n// Custom transformation with as()\nfinal user = r.as((res) =\u003e User.fromJson(res.json()));\n```\n\n### Parsing into a model with `as()`\n\n`as\u003cT\u003e()` applies a custom transformer to the response, letting you map the raw JSON directly into your own model class:\n\n```dart\nclass User {\n  final int id;\n  final String name;\n  final String email;\n\n  User.fromJson(Map\u003cString, dynamic\u003e json)\n      : id = json['id'] as int,\n        name = json['name'] as String,\n        email = json['email'] as String;\n}\n\nfinal response = await connector.send(GetUserRequest(42));\nfinal user = response.as((r) =\u003e User.fromJson(r.json()));\nprint(user.name);  // Alice\n```\n\nFor a list of objects:\n\n```dart\nfinal response = await connector.send(GetUsersRequest());\nfinal users = response.as(\n  (r) =\u003e r.jsonList().map((e) =\u003e User.fromJson(e as Map\u003cString, dynamic\u003e)).toList(),\n);\nprint(users.length); // 10\n```\n\n---\n\n## Error handling\n\nWhen `throwOnError` is `true` (default), Lucky throws typed exceptions for non-2xx responses:\n\n```dart\ntry {\n  final r = await connector.send(GetUserRequest(42));\n  print(r.json()['name']);\n\n} on NotFoundException catch (e) {\n  // 404 — e.statusCode == 404\n  print('User not found: ${e.message}');\n\n} on UnauthorizedException catch (e) {\n  // 401\n  print('Authentication required');\n\n} on ValidationException catch (e) {\n  // 422 — e.errors contains the field errors map\n  e.errors?.forEach((field, messages) {\n    print('$field: $messages');\n  });\n\n} on LuckyTimeoutException catch (e) {\n  // Connection or read timeout\n  print('Request timed out');\n\n} on ConnectionException catch (e) {\n  // Network unreachable, DNS failure, etc.\n  print('Network error: ${e.message}');\n\n} on LuckyException catch (e) {\n  // Any other HTTP error (5xx, etc.)\n  print('HTTP ${e.statusCode}: ${e.message}');\n}\n```\n\n### Parse errors\n\nThe parsing helpers (`json()`, `jsonList()`, `text()`, `bytes()`) throw `LuckyParseException` when the response body is not the expected type. This is a client-side error (no HTTP status code), but it lives in the same `LuckyException` hierarchy:\n\n```dart\ntry {\n  final user = response.json();\n} on LuckyParseException catch (e) {\n  print(e.message); // \"Expected Map\u003cString, dynamic\u003e, got String\"\n  print(e.cause);   // the original TypeError\n}\n```\n\n---\n\n**To handle status codes manually**, disable throwing:\n\n```dart\nclass MyConnector extends Connector {\n  @override bool get throwOnError =\u003e false;\n}\n\nfinal r = await connector.send(SomeRequest());\nif (r.isSuccessful) {\n  // ...\n} else if (r.statusCode == 404) {\n  // ...\n}\n```\n\n---\n\n## Retry\n\nLucky can automatically retry failed requests. Attach a `RetryPolicy` to your connector by overriding the `retryPolicy` getter.\n\n### How retry works\n\nEvery time `send()` runs, it starts a `while` loop. On each iteration:\n\n1. The throttle policy runs (if configured) — see [Throttle](#throttle)\n2. The HTTP request is dispatched\n3. If the response status is in the retry set (e.g. 503), `shouldRetryOnResponse` returns `true` and the loop continues after a delay\n4. If a network exception is thrown (connection error, timeout), `shouldRetryOnException` returns `true` and the loop continues\n5. Once `maxAttempts` is reached or neither condition is met, the response (or exception) is returned normally\n\n```\nattempt 1  ──── 503 ──► shouldRetryOnResponse? yes ──► wait 500ms ──►\nattempt 2  ──── 503 ──► shouldRetryOnResponse? yes ──► wait 1000ms ──►\nattempt 3  ──── 503 ──► maxAttempts reached ──► return 503 response\n```\n\n`RetryPolicy` is **stateless** — it is a getter re-evaluated on every `send()` call, so `const` constructors work perfectly.\n\n### ExponentialBackoffRetryPolicy\n\nThe built-in implementation retries with exponentially increasing delays, capped at `maxDelay`:\n\n```\ndelay(n) = min(initialDelay × multiplier^(n-1), maxDelay)\n```\n\nWith defaults (`initialDelay=500ms`, `multiplier=2`, `maxDelay=30s`):\n\n| Retry | Delay before |\n|---|---|\n| 1st | 500 ms |\n| 2nd | 1 000 ms |\n| 3rd | 2 000 ms |\n| 4th | 4 000 ms |\n| … | … (capped at 30 s) |\n\n**Default retry triggers:**\n\n| Condition | Retried? |\n|---|---|\n| HTTP 429, 500, 502, 503, 504 | ✅ Yes |\n| `ConnectionException` | ✅ Yes |\n| `LuckyTimeoutException` | ✅ Yes |\n| HTTP 400, 401, 404, 422 | ❌ No |\n\n```dart\nclass MyConnector extends Connector {\n  @override\n  String resolveBaseUrl() =\u003e 'https://api.example.com';\n\n  // 4 total attempts (1 initial + 3 retries), start at 1s\n  @override\n  RetryPolicy? get retryPolicy =\u003e const ExponentialBackoffRetryPolicy(\n    maxAttempts: 4,\n    initialDelay: Duration(seconds: 1),\n  );\n}\n```\n\nCustomise which status codes trigger a retry:\n\n```dart\n@override\nRetryPolicy? get retryPolicy =\u003e const ExponentialBackoffRetryPolicy(\n  maxAttempts: 3,\n  retryOnStatusCodes: {503, 504}, // only retry gateway errors\n);\n```\n\n### LinearBackoffRetryPolicy\n\nWaits the same fixed delay between every retry. Use this when the downstream service has a known, stable recovery time:\n\n```dart\n@override\nRetryPolicy? get retryPolicy =\u003e const LinearBackoffRetryPolicy(\n  maxAttempts: 4,\n  delay: Duration(seconds: 2), // always 2s between attempts\n);\n```\n\n### ImmediateRetryPolicy\n\nRetries with no delay at all. For truly transient errors expected to resolve within milliseconds (packet loss, brief DNS hiccup):\n\n```dart\n@override\nRetryPolicy? get retryPolicy =\u003e const ImmediateRetryPolicy(maxAttempts: 2);\n```\n\n\u003e Prefer `ExponentialBackoffRetryPolicy` for 5xx errors to avoid hammering an already struggling service.\n\n### Jitter\n\n`JitteredRetryPolicy` is a **decorator** that adds a bounded random delay on top of any retry policy. It solves the *thundering herd problem*: without jitter, clients failing simultaneously all retry at the same instant and amplify the outage.\n\n```dart\n// Scraping: 10s base + 0–2s noise → requests fire between 10s and 12s\nJitteredRetryPolicy(\n  inner: LinearBackoffRetryPolicy(delay: Duration(seconds: 10)),\n  maxJitter: Duration(seconds: 2),\n  strategy: JitterStrategy.full,  // [base, base + maxJitter]\n)\n\n// Cloud API: exponential backoff with equal jitter\nJitteredRetryPolicy(\n  inner: const ExponentialBackoffRetryPolicy(maxAttempts: 4),\n  maxJitter: Duration(milliseconds: 500),\n  strategy: JitterStrategy.equal, // [base + maxJitter/2, base + maxJitter]\n)\n```\n\nThe jitter is always **additive** — the base delay from the inner policy is preserved and the noise is added on top.\n\n**Available strategies:**\n\n| Strategy | Formula | Example (base=10s, maxJitter=2s) |\n|---|---|---|\n| `none` | base unchanged | 10s |\n| `full` | base + random(0, maxJitter) | 10–12s |\n| `equal` | base + random(maxJitter/2, maxJitter) | 11–12s |\n\nFor deterministic tests, inject a seeded `Random`:\n\n```dart\nJitteredRetryPolicy(\n  inner: const LinearBackoffRetryPolicy(),\n  maxJitter: Duration(seconds: 1),\n  random: Random(42), // fixed seed → reproducible delays\n)\n```\n\n### Custom RetryPolicy\n\nImplement `RetryPolicy` directly for full control:\n\n```dart\nclass OnlyOn503RetryPolicy extends RetryPolicy {\n  const OnlyOn503RetryPolicy();\n\n  @override\n  int get maxAttempts =\u003e 5;\n\n  @override\n  bool shouldRetryOnResponse(LuckyResponse response, int attempt) =\u003e\n      response.statusCode == 503;\n\n  @override\n  bool shouldRetryOnException(LuckyException exception, int attempt) =\u003e false;\n\n  @override\n  Duration delayFor(int attempt) =\u003e Duration(seconds: attempt); // 1s, 2s, 3s…\n}\n```\n\n\u003e **Note:** `shouldRetryOnException` is also called when `throwOnError = true` causes Lucky to throw a typed exception (e.g. `NotFoundException` for a 404). Make sure your policy returns the expected value for both network-level and HTTP-level exceptions.\n\n---\n\n## Throttle\n\nLucky can pace outgoing requests to respect API rate limits. Attach a `ThrottlePolicy` to your connector by overriding the `throttlePolicy` getter.\n\n### How throttle works\n\n`acquire()` is called **before every attempt**, including retries. It blocks until a request slot is available:\n\n```\nsend() called\n  └─► throttlePolicy.acquire()   ← waits if rate limit exceeded\n       └─► HTTP request dispatched\n            └─► [retry? throttle runs again before next attempt]\n```\n\nIf the computed wait time exceeds `maxWaitTime`, `acquire()` throws `LuckyThrottleException` immediately instead of waiting. This exception **is never retried**, even when a `RetryPolicy` is configured.\n\n`ThrottlePolicy` is **stateful** — it tracks recent request timestamps. You must store the instance in a field on the connector; recreating it in the getter loses all history:\n\n```dart\n// ❌ Broken — state is lost on every send() call\n@override\nThrottlePolicy? get throttlePolicy =\u003e RateLimitThrottlePolicy(...);\n\n// ✅ Correct — single instance, state persists\nfinal _throttle = RateLimitThrottlePolicy(...);\n\n@override\nThrottlePolicy? get throttlePolicy =\u003e _throttle;\n```\n\n### RateLimitThrottlePolicy\n\nThe built-in implementation uses a **sliding window**: it records the timestamp of each `acquire()` call and evicts entries older than `windowDuration` before checking the slot count.\n\n```dart\nclass WeatherConnector extends Connector {\n  // Max 10 requests per second\n  final _throttle = RateLimitThrottlePolicy(\n    maxRequests: 10,\n    windowDuration: Duration(seconds: 1),\n  );\n\n  @override\n  String resolveBaseUrl() =\u003e 'https://api.openweathermap.org';\n\n  @override\n  ThrottlePolicy? get throttlePolicy =\u003e _throttle;\n}\n```\n\nWith `maxWaitTime`, requests that would wait too long fail fast instead of blocking:\n\n```dart\nfinal _throttle = RateLimitThrottlePolicy(\n  maxRequests: 5,\n  windowDuration: Duration(seconds: 1),\n  maxWaitTime: Duration(milliseconds: 200), // throw instead of waiting \u003e 200ms\n);\n```\n\nHandle the exception:\n\n```dart\ntry {\n  final r = await connector.send(MyRequest());\n} on LuckyThrottleException catch (e) {\n  // Rate limit exceeded and maxWaitTime was hit\n  print('Too many requests: ${e.message}');\n}\n```\n\n### TokenBucketThrottlePolicy\n\nAllows **controlled bursts**: tokens accumulate during periods of inactivity and can be consumed rapidly up to the bucket capacity. This is the model used by GitHub, Stripe, and most REST APIs, making it the most faithful client-side implementation of their limits.\n\n```dart\nclass MyConnector extends Connector {\n  // 10 req/s sustained, burst up to 20\n  final _throttle = TokenBucketThrottlePolicy(\n    capacity: 20,\n    refillRate: 10.0, // tokens refilled per second\n  );\n\n  @override\n  ThrottlePolicy? get throttlePolicy =\u003e _throttle;\n}\n```\n\nWith `maxWaitTime`, fail fast instead of blocking when the bucket is too empty:\n\n```dart\nfinal _throttle = TokenBucketThrottlePolicy(\n  capacity: 5,\n  refillRate: 2.0,\n  maxWaitTime: Duration(milliseconds: 500),\n);\n```\n\n**Comparison with `RateLimitThrottlePolicy`:**\n\n| | RateLimitThrottlePolicy | TokenBucketThrottlePolicy |\n|---|---|---|\n| Model | Strict sliding window | Token bucket |\n| Burst | ❌ No | ✅ Yes (up to `capacity`) |\n| API fidelity | Good | Excellent (GitHub, Stripe…) |\n\n### ConcurrencyThrottlePolicy\n\nLimits the number of requests **in flight simultaneously**, independently of throughput. Useful when the downstream API throttles on concurrency rather than rate, for HTTP/1.1 connections, or to cap parallel calls in resource-constrained environments.\n\n```dart\nclass MyConnector extends Connector {\n  final _throttle = ConcurrencyThrottlePolicy(maxConcurrent: 3);\n\n  @override\n  ThrottlePolicy? get throttlePolicy =\u003e _throttle;\n}\n```\n\nWaiters are served in FIFO order. `release()` is called automatically by `Connector.send()` after every attempt via a `try/finally` block.\n\nWith `maxWaitTime`:\n\n```dart\nfinal _throttle = ConcurrencyThrottlePolicy(\n  maxConcurrent: 3,\n  maxWaitTime: Duration(seconds: 2), // throw if no slot available within 2s\n);\n```\n\n---\n\n## Combining retry and throttle\n\nBoth policies can be active simultaneously on the same connector. The throttle always runs first — every retry attempt passes through `acquire()` before the request is sent:\n\n```dart\nclass ApiConnector extends Connector {\n  // Throttle: max 5 requests/second, never wait more than 500ms\n  final _throttle = RateLimitThrottlePolicy(\n    maxRequests: 5,\n    windowDuration: Duration(seconds: 1),\n    maxWaitTime: Duration(milliseconds: 500),\n  );\n\n  @override\n  String resolveBaseUrl() =\u003e 'https://api.example.com';\n\n  @override\n  ThrottlePolicy? get throttlePolicy =\u003e _throttle;\n\n  // Retry: up to 3 attempts on 429/5xx and network errors\n  @override\n  RetryPolicy? get retryPolicy =\u003e const ExponentialBackoffRetryPolicy();\n}\n```\n\n**Interaction rules:**\n\n| Situation | What happens |\n|---|---|\n| 503 → retry → throttle allows slot | Request retried after throttle delay + backoff delay |\n| 503 → retry → throttle blocks \u003e maxWaitTime | `LuckyThrottleException` thrown, **no further retry** |\n| `LuckyThrottleException` thrown | Always propagated immediately, retry loop never entered |\n\nThe key invariant: a `LuckyThrottleException` is **never** passed to `shouldRetryOnException`. Once the throttle rejects a request, it's over.\n\n---\n\n## Logging \u0026 debug\n\nLucky has no built-in logger. Wire your own callback — works with `print`, `logger`, `talker`, or any other system:\n\n```dart\nclass MyConnector extends Connector {\n  @override\n  bool get enableLogging =\u003e true;\n\n  @override\n  LuckyLogCallback get onLog =\u003e ({required message, level, context}) {\n    // Wire to your favourite logger\n    print('[$level] $message');\n  };\n\n  // More verbose structured output — use kDebugMode in Flutter, or true/false in Dart\n  @override\n  bool get debugMode =\u003e true;\n\n  @override\n  LuckyDebugCallback get onDebug =\u003e ({required event, message, data}) {\n    print('DEBUG [$event] $message\\n$data');\n  };\n}\n```\n\nThe `LuckyLogCallback` and `LuckyDebugCallback` typedefs are exported from the package, so you can use them to type your own callback variables:\n\n```dart\nfinal LuckyLogCallback myLogger = ({required message, level, context}) {\n  talker.log(message, logLevel: level);\n};\n```\n\nTo suppress logging for a specific request (e.g. one that carries credentials):\n\n```dart\nclass LoginRequest extends Request with HasFormBody {\n  @override bool get logRequest =\u003e false;  // don't log the request body\n  @override bool get logResponse =\u003e false; // don't log the response token\n  // ...\n}\n```\n\n---\n\n## Custom interceptors\n\nAttach any Dio `Interceptor` to the connector:\n\n```dart\nclass MyConnector extends Connector {\n  @override\n  List\u003cInterceptor\u003e get interceptors =\u003e [\n    MyRetryInterceptor(),\n    MyCacheInterceptor(),\n  ];\n}\n```\n\n---\n\n## Why Lucky?\n\n**Lucky Dart** is named after **Lucky Luke** — the cowboy who shoots faster than his shadow. Because that's what this package is about: making your API calls fast and elegant, without the ceremony.\n\nThis project is inspired by **[Saloon PHP](https://docs.saloon.dev/)**, a fantastic package for building structured API integrations in PHP/Laravel. Lucky Dart brings the same philosophy to Dart and Flutter:\n\n- One class per API (**Connector**)\n- One class per endpoint (**Request**)\n- No code generation\n- No magic, just clean OOP\n\n---\n\n*Built with ❤️ by [OwlNext](https://github.com/owlnext-fr)*\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fowlnext-fr%2Flucky","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fowlnext-fr%2Flucky","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fowlnext-fr%2Flucky/lists"}