1
|
|
|
<?php |
2
|
|
|
|
3
|
|
|
namespace DrewM\MailChimp; |
4
|
|
|
|
5
|
|
|
/** |
6
|
|
|
* An error handler for MailChimp. |
7
|
|
|
* Help MailChimp error classes, categorization |
8
|
|
|
* and determinate whether a failure is, eg soft/hard. |
9
|
|
|
* |
10
|
|
|
* Example: |
11
|
|
|
* |
12
|
|
|
* if ($code & ErrorHandler::ALREADY_EXISTS) { |
13
|
|
|
* // return "ok"; |
14
|
|
|
* } elseif ($code & ErrorHandler::SOFT_FAILURE) { |
15
|
|
|
* // return "ok"; |
16
|
|
|
* } elseif ($code & (ErrorHandler::NET_TIMEOUT | ErrorHandler::NET_NO_JSON)) { |
17
|
|
|
* // return "retry", |
18
|
|
|
* } |
19
|
|
|
* |
20
|
|
|
* @author Raphaël Droz <[email protected]> |
21
|
|
|
* |
22
|
|
|
*/ |
23
|
|
|
class ErrorHandler |
24
|
|
|
{ |
25
|
|
|
// General return categories: OK or failure (By default, failures are most probably API rejections) |
26
|
|
|
const OK = 1 << 0; |
27
|
|
|
const FAILURE = 1 << 1; |
28
|
|
|
|
29
|
|
|
// "Network" failure "subcategory" |
30
|
|
|
const NET_FAILURE = 1 << 2; |
31
|
|
|
|
32
|
|
|
// Kind of failure "tag". Some network or API errors are judged "soft" or "temporary". |
33
|
|
|
// It's up to the callee to test SOFT_FAILURE congruence and make use of this information if desired. |
34
|
|
|
const SOFT_FAILURE = 1 << 3; |
35
|
|
|
|
36
|
|
|
// successes |
37
|
|
|
const INSERTED = 1 << 6; |
38
|
|
|
const UPDATED = 1 << 7; |
39
|
|
|
const DELETED = 1 << 8; |
40
|
|
|
|
41
|
|
|
// api rejections |
42
|
|
|
const INVALID_RES = 1 << 10; // usually from a programmer error |
43
|
|
|
const INVALID_API_KEY = 1 << 11; // idem |
44
|
|
|
const ALREADY_EXISTS = 1 << 12; |
45
|
|
|
const COMPLIANCE = 1 << 13; |
46
|
|
|
const FAKE = 1 << 14; |
47
|
|
|
const INVALID_DOMAIN = 1 << 15; |
48
|
|
|
const CAPPED = 1 << 16; |
49
|
|
|
const API_OTHER = 1 << 17; |
50
|
|
|
const INVALID_ADDRESS = 1 << 18; // invalid ADDRESS merge field |
51
|
|
|
|
52
|
|
|
// network failures |
53
|
|
|
const NET_REQ_PROC = 1 << 20; // Neither permanent nor record-specific error. try again later. |
54
|
|
|
const NET_TIMEOUT = 1 << 21; // curl: Operation timed out |
55
|
|
|
const NET_NO_JSON = 1 << 22; // non-JSON response |
56
|
|
|
const NET_UNKNOWN = 1 << 23; |
57
|
|
|
const UNKNOWN_ERROR = 1 << 24; |
58
|
|
|
|
59
|
|
|
/** |
60
|
|
|
* A couple of shortcuts to directly return a specific error of a given category |
61
|
|
|
*/ |
62
|
|
|
private function ok(int $r, array $a) |
63
|
|
|
{ |
64
|
|
|
return $this->log($r | self::OK, $a); |
65
|
|
|
} |
66
|
|
|
private function fail(int $r, array $a) |
67
|
|
|
{ |
68
|
|
|
return $this->log($r | self::FAILURE, $a); |
69
|
|
|
} |
70
|
|
|
private function netfail(int $r, array $a) |
71
|
|
|
{ |
72
|
|
|
return $this->fail($r | self::NET_FAILURE, $a); |
73
|
|
|
} |
74
|
|
|
|
75
|
|
|
/** |
76
|
|
|
* A list of well-known failures. |
77
|
|
|
* They are tested in order. Put more restrictive first |
78
|
|
|
* At the step where these are probed, |
79
|
|
|
* response is certainly a *failure*, as such all below return codes must be also be OR-ed with self::FAILURE |
80
|
|
|
*/ |
81
|
|
|
const CODES = [ |
82
|
|
|
'400' => [ |
83
|
|
|
// See https://developer.mailchimp.com/documentation/mailchimp/guides/error-glossary/ for categories |
84
|
|
|
// 'BadRequest', // Your request could not be processed. |
85
|
|
|
// 'InvalidAction', |
86
|
|
|
// 'InvalidResource', // The resource submitted could not be validated. |
87
|
|
|
// 'JSONParseError', |
88
|
|
|
['code' => self::ALREADY_EXISTS, |
89
|
|
|
'tests' => [ 'title' => 'Member Exists', |
90
|
|
|
'detail' => 'is already a list member']], |
91
|
|
|
|
92
|
|
|
['code' => self::COMPLIANCE, |
93
|
|
|
'tests' => ['title' => 'Member In Compliance State', |
94
|
|
|
'detail' => 'is in a compliance state due to unsubscribe, bounce, or compliance review']], |
95
|
|
|
|
96
|
|
|
['code' => self::INVALID_API_KEY | self::SOFT_FAILURE, |
97
|
|
|
'tests' => ['title' => 'API Key Invalid']], |
98
|
|
|
|
99
|
|
|
['code' => self::FAKE, |
100
|
|
|
'tests' => ['title' => 'Invalid Resource', |
101
|
|
|
'detail' => 'looks fake or invalid']], |
102
|
|
|
|
103
|
|
|
['code' => self::INVALID_ADDRESS, |
104
|
|
|
'tests' => ['title' => 'Invalid Resource', |
105
|
|
|
'detail' => 'Your merge fields were invalid', |
106
|
|
|
'errors' => 'Please enter a complete address']], |
107
|
|
|
|
108
|
|
|
['code' => self::INVALID_DOMAIN, |
109
|
|
|
'tests' => ['title' => 'Invalid Resource', |
110
|
|
|
'detail' => 'domain portion of the email address is invalid']], |
111
|
|
|
|
112
|
|
|
['code' => self::CAPPED, |
113
|
|
|
'tests' => ['title' => 'Invalid Resource', |
114
|
|
|
'detail' => 'has signed up to a lot of lists very recently']], |
115
|
|
|
|
116
|
|
|
['code' => self::INVALID_RES | self::SOFT_FAILURE, |
117
|
|
|
'tests' => ['title' => 'Invalid Resource', |
118
|
|
|
'detail' => 'The resource submitted could not be validated']], |
119
|
|
|
], |
120
|
|
|
/* |
121
|
|
|
'401' => [ |
122
|
|
|
'APIKeyMissing', |
123
|
|
|
'APIKeyInvalid', |
124
|
|
|
], |
125
|
|
|
'403' => [ |
126
|
|
|
'Forbidden', |
127
|
|
|
'UserDisabled', |
128
|
|
|
'WrongDatacenter' |
129
|
|
|
], |
130
|
|
|
'404' => [ |
131
|
|
|
'ResourceNotFound' |
132
|
|
|
] |
133
|
|
|
'405' => [ |
134
|
|
|
'MethodNotAllowed' |
135
|
|
|
], |
136
|
|
|
'414' => [ |
137
|
|
|
'ResourceNestingTooDeep' |
138
|
|
|
], |
139
|
|
|
'422' => [ |
140
|
|
|
'InvalidMethodOverride' |
141
|
|
|
], |
142
|
|
|
'429' => [ |
143
|
|
|
'TooManyRequests' |
144
|
|
|
], |
145
|
|
|
'500' => [ |
146
|
|
|
'InternalServerError' |
147
|
|
|
], |
148
|
|
|
'503' => [ |
149
|
|
|
'ComplianceRelated' |
150
|
|
|
] |
151
|
|
|
*/ |
152
|
|
|
]; |
153
|
|
|
|
154
|
|
|
/** |
155
|
|
|
* Simple categorization, exclusively used for logging. |
156
|
|
|
*/ |
157
|
|
|
const ERROR_CLASSES = [ |
158
|
|
|
self::COMPLIANCE => 'compliance-state', |
159
|
|
|
self::FAKE => 'fake', |
160
|
|
|
self::INVALID_DOMAIN => 'invalid-domain', |
161
|
|
|
self::CAPPED => 'capped' |
162
|
|
|
]; |
163
|
|
|
|
164
|
|
|
// Unused |
165
|
|
|
const MC_RETURN_CODES = [ ['code' => -100, 'name' => 'ValidationError'], |
166
|
|
|
['code' => -99, 'name' => 'List_RoleEmailMember'], |
167
|
|
|
['code' => 212, 'name' => 'List_InvalidUnsubMember'], |
168
|
|
|
['code' => 213, 'name' => 'List_InvalidBounceMember'], |
169
|
|
|
['code' => 234, 'name' => 'List_ThrottledRecipient'] ]; |
170
|
|
|
|
171
|
|
|
protected $logger; |
172
|
|
|
|
173
|
|
|
public function __construct(?\Psr\Log\LoggerInterface $logger) |
174
|
|
|
{ |
175
|
|
|
if ($logger) { |
176
|
|
|
$this->logger = $logger; |
177
|
|
|
} else { |
178
|
|
|
$this->logger = new class |
179
|
|
|
{ |
180
|
|
|
public function __call($name, $args) |
181
|
|
|
{ |
182
|
|
|
printf('[%s] %s' . PHP_EOL, strtoupper($name), $args[0]); |
183
|
|
|
} |
184
|
|
|
}; |
185
|
|
|
} |
186
|
|
|
} |
187
|
|
|
|
188
|
|
|
/** |
189
|
|
|
* @param MailChimp $mailchimp A MailChimp class. |
190
|
|
|
* @param array $json_response The MailChimp response. |
191
|
|
|
* @param string $method The method used in the request. |
192
|
|
|
* @param array $loginfo Additional parameter(s) to pass as logging variables. |
193
|
|
|
* |
194
|
|
|
* Example: |
195
|
|
|
* |
196
|
|
|
* $response = $mc->post($url, $some_data); |
197
|
|
|
* return $errorHandler->errno($mc, $response, 'POST', ['id' => $request_id]) |
198
|
|
|
* |
199
|
|
|
*/ |
200
|
|
|
public function errno(MailChimp $mailchimp, array $json_response, string $method, array $loginfo = []) |
201
|
|
|
{ |
202
|
|
|
$http_code = $mailchimp->getLastResponse()['headers']['http_code']; |
203
|
|
|
$body = $mailchimp->getLastResponse()['body']; |
204
|
|
|
$detail = $json_response['detail'] ?? ''; |
|
|
|
|
205
|
|
|
// $logargs is just an array of arguments which improve error logging |
206
|
|
|
$logargs = compact('json_response', 'mailchimp') + $loginfo; |
207
|
|
|
|
208
|
|
|
/** |
209
|
|
|
* Handles anything from network to webserver failures through content-type problems. |
210
|
|
|
* Basically any case where no valid JSON is not returned with a 2xx code. |
211
|
|
|
*/ |
212
|
|
|
if (! $json_response) { |
|
|
|
|
213
|
|
|
// Not an error, just a response to DELETE |
214
|
|
|
if ($method === 'DELETE' && $http_code === 204) { |
215
|
|
|
return $this->ok(self::DELETED, $logargs); |
216
|
|
|
} |
217
|
|
|
|
218
|
|
|
if (strpos($body, 'An error occurred while processing your request') !== false) { |
219
|
|
|
return $this->netfail(self::NET_NO_JSON | self::SOFT_FAILURE | self::NET_REQ_PROC, $logargs); |
220
|
|
|
} elseif ($body) { |
221
|
|
|
return $this->netfail(self::NET_NO_JSON, $logargs); |
222
|
|
|
} |
223
|
|
|
|
224
|
|
|
if (strpos($mailchimp->getLastError(), 'Operation timed out') === 0) { |
225
|
|
|
return $this->netfail(self::NET_TIMEOUT | self::SOFT_FAILURE, $logargs); |
226
|
|
|
} |
227
|
|
|
return $this->netfail($mailchimp->getLastError() ? self::NET_FAILURE : self::NET_UNKNOWN, $logargs); |
228
|
|
|
} |
229
|
|
|
|
230
|
|
|
if (empty($json_response['status'])) { |
231
|
|
|
return $this->fail(self::UNKNOWN_ERROR, $logargs); |
232
|
|
|
} |
233
|
|
|
|
234
|
|
|
if (!empty($json_response['email_address'])) { |
235
|
|
|
if ($method === 'POST') { |
236
|
|
|
return $this->ok(self::INSERTED, $logargs); |
237
|
|
|
} |
238
|
|
|
if ($method === 'PUT') { |
239
|
|
|
return $this->ok(self::UPDATED, $logargs); |
240
|
|
|
} |
241
|
|
|
if ($method === 'PATCH') { |
242
|
|
|
return $this->ok(self::UPDATED, $logargs); |
243
|
|
|
} |
244
|
|
|
} |
245
|
|
|
|
246
|
|
|
/** |
247
|
|
|
* Attempt to "recognize" a MailChimp API error: |
248
|
|
|
* "status" could very well be a "real" MailChimp status, like in "status": "unsubscribed" |
249
|
|
|
* (and "unsubscribed" != 200) |
250
|
|
|
* In order to detect an error, response must be: |
251
|
|
|
* 1. numeric |
252
|
|
|
* 2. code != 200 |
253
|
|
|
* 3. "detail" key must be present |
254
|
|
|
*/ |
255
|
|
|
$isFailed = is_numeric($json_response['status']) |
256
|
|
|
&& $json_response['status'] != '200' |
257
|
|
|
&& isset($json_response['detail']); |
258
|
|
|
|
259
|
|
|
if (! $isFailed) { |
260
|
|
|
var_dump("No place for success in this codepath. api-wtf.", $json_response); |
|
|
|
|
261
|
|
|
return $this->fail(self::UNKNOWN_ERROR, $logargs); |
262
|
|
|
} |
263
|
|
|
|
264
|
|
|
foreach (self::CODES as $status_code => $errors) { |
265
|
|
|
// Note self::CODES we define JSON response "status" codes. Anyway it does not seem needed to test for them. |
266
|
|
|
foreach ($errors as $error) { |
267
|
|
|
extract($error); // defines $code and $tests |
268
|
|
|
if (!$tests) { |
269
|
|
|
continue; |
270
|
|
|
} |
271
|
|
|
$match = true; |
272
|
|
|
foreach ($tests as $field => $val) { |
273
|
|
|
if ($json_response[$field] === $val |
274
|
|
|
|| ($field === 'detail' && strpos($json_response['detail'], $val) !== false) |
275
|
|
|
|| ($field === 'errors' && strpos(json_encode($json_response['errors']), $val) !== false)) { |
276
|
|
|
// pass |
277
|
|
|
} else { |
278
|
|
|
$match = false; |
279
|
|
|
break; |
280
|
|
|
} |
281
|
|
|
} |
282
|
|
|
if ($match) { |
283
|
|
|
return $this->fail($code, $logargs); |
284
|
|
|
} |
285
|
|
|
} |
286
|
|
|
} |
287
|
|
|
|
288
|
|
|
return $this->fail(self::API_OTHER, $logargs); |
289
|
|
|
} |
290
|
|
|
|
291
|
|
|
/** |
292
|
|
|
* Logging API errors into the database is wrong. |
293
|
|
|
* It's the role of our logger to perform well enough |
294
|
|
|
*/ |
295
|
|
|
public function log(int $code, array $logargs) |
296
|
|
|
{ |
297
|
|
|
$logger = $this->logger; |
298
|
|
|
extract($logargs); // defines $id, $json_response, $mailchimp |
299
|
|
|
|
300
|
|
|
$last_request = self::obfuscateRequest($mailchimp->getLastRequest()); |
301
|
|
|
$last_response = self::obfuscateRequest($mailchimp->getLastResponse()); |
302
|
|
|
|
303
|
|
|
$data = [ |
304
|
|
|
'code' => $code, |
305
|
|
|
'method' => $last_request['method'], |
306
|
|
|
'substatus' => $last_request['body']['status'] ?? '', |
307
|
|
|
'email' => $last_request['body']['email_address'] ?? '', |
308
|
|
|
'http_code' => $last_response['headers']['http_code'], |
309
|
|
|
'json_response' => json_encode($json_response), |
310
|
|
|
'mc_error' => $mailchimp->getLastError(), |
311
|
|
|
]; |
312
|
|
|
|
313
|
|
|
$prefix = 'mailchimp-api {method}'; |
314
|
|
|
if (!empty($id)) { |
315
|
|
|
$prefix .= ', id={id}'; |
316
|
|
|
$data['id'] = $id; |
317
|
|
|
} |
318
|
|
|
|
319
|
|
|
if ($code & self::OK) { |
320
|
|
|
$logger->debug(self::interpolate($prefix . ': OK', $data)); |
|
|
|
|
321
|
|
|
return $code; |
322
|
|
|
} |
323
|
|
|
|
324
|
|
|
if (!empty($id) && !empty($data['email'])) { |
325
|
|
|
$prefix .= '/{email}'; |
326
|
|
|
} |
327
|
|
|
|
328
|
|
|
// Grab corresponding error class as string. |
329
|
|
|
$type = array_filter(self::ERROR_CLASSES, function ($string, $return_code) use ($code) { |
330
|
|
|
return $code & $return_code; |
331
|
|
|
}, ARRAY_FILTER_USE_BOTH); |
332
|
|
|
$type = array_shift($type); |
333
|
|
|
if ($type) { |
334
|
|
|
$prefix .= ' [{type}]'; |
335
|
|
|
$data['type'] = $type; |
336
|
|
|
} |
337
|
|
|
// import `json_response` and request (like `method`) keys to be usable in the logs |
338
|
|
|
$data += $json_response + $last_request; |
339
|
|
|
$data['method'] = strtoupper($data['method']); |
340
|
|
|
|
341
|
|
|
if ($code & self::NET_FAILURE) { |
342
|
|
|
$prefix .= ': http={http_code} network error'; |
343
|
|
|
if ($code & self::SOFT_FAILURE) { |
344
|
|
|
$prefix .= '. [Temporary] Reason: {mc_error}'; |
345
|
|
|
} |
346
|
|
|
} |
347
|
|
|
if ($code & self::NET_NO_JSON) { |
348
|
|
|
$logger->error(self::interpolate($prefix . ': json={json_response}, body={body}', $data)); |
|
|
|
|
349
|
|
|
} elseif (($r['status'] ?? null) == '400') { |
350
|
|
|
$logger->warning(self::interpolate($prefix . ': {substatus} other error: {response}', $data)); |
|
|
|
|
351
|
|
|
} |
352
|
|
|
if ($code & self::ALREADY_EXISTS) { |
353
|
|
|
$data['detail'] = preg_replace('/Use PUT.*/', '', $data['detail']); |
354
|
|
|
$logger->debug(self::interpolate($prefix . ': {detail}', $data)); |
|
|
|
|
355
|
|
|
return $code; |
356
|
|
|
} elseif ($code & self::INVALID_ADDRESS) { |
357
|
|
|
$logger->warning(self::interpolate($prefix . ': Invalid address: {body}', $data)); |
|
|
|
|
358
|
|
|
return $code; |
359
|
|
|
} |
360
|
|
|
|
361
|
|
|
// Dump as much as possible to further improve and predict errors |
362
|
|
|
if ($code & self::FAILURE) { |
363
|
|
|
if ($code & self::SOFT_FAILURE) { |
364
|
|
|
$logger->debug(self::interpolate($prefix . ': soft-fail {code}', $data)); |
|
|
|
|
365
|
|
|
} else { |
366
|
|
|
$logger->warning(self::interpolate($prefix . ': status={status},title={title},detail={detail}', $data)); |
|
|
|
|
367
|
|
|
} |
368
|
|
|
if (!$type) { |
369
|
|
|
$logger->debug(self::interpolate('{request} ++ {response} -- {json}', ['request' => $last_request, |
|
|
|
|
370
|
|
|
'response' => $last_response, |
371
|
|
|
'json' => $json_response])); |
372
|
|
|
} |
373
|
|
|
} |
374
|
|
|
return $code; |
375
|
|
|
} |
376
|
|
|
|
377
|
|
|
/** |
378
|
|
|
* Monolog default LineFormatter appends the whole context at the end of the log line. |
379
|
|
|
* Even if elements are used as message placeholders. This (PSR-3) interpolation function |
380
|
|
|
* avoids this. |
381
|
|
|
*/ |
382
|
|
|
private static function interpolate(string $message, array $context = []) |
383
|
|
|
{ |
384
|
|
|
// build a replacement array with braces around the context keys |
385
|
|
|
$replace = []; |
386
|
|
|
foreach ($context as $key => $val) { |
387
|
|
|
// check that the value can be casted to string |
388
|
|
|
if (!is_array($val) && !is_object($val) || method_exists($val, '__toString')) { |
389
|
|
|
$replace['{' . $key . '}'] = $val; |
390
|
|
|
} else { |
391
|
|
|
$replace['{' . $key . '}'] = json_encode($val); |
392
|
|
|
} |
393
|
|
|
} |
394
|
|
|
|
395
|
|
|
// interpolate replacement values into the message and return |
396
|
|
|
return strtr($message, $replace); |
397
|
|
|
} |
398
|
|
|
|
399
|
|
|
public static function obfuscateRequest($request) |
400
|
|
|
{ |
401
|
|
|
if (!isset($request['headers'])) { |
402
|
|
|
return $request; |
403
|
|
|
} |
404
|
|
|
|
405
|
|
|
if (isset($request['headers']['request_header'])) { |
406
|
|
|
$request['headers']['request_header'] = preg_replace( |
407
|
|
|
'/[0-9a-f]{32}(-us\d+)/', |
408
|
|
|
str_repeat('*', 32) . '\1', |
409
|
|
|
$request['headers']['request_header'] |
410
|
|
|
); |
411
|
|
|
} elseif (is_string($request['headers'])) { |
412
|
|
|
$request['headers'] = preg_replace( |
413
|
|
|
'/apikey [0-9a-f]{32}(-us\d+)/', |
414
|
|
|
'apikey ' . str_repeat('*', 32) . '\1', |
415
|
|
|
$request['headers'] |
416
|
|
|
); |
417
|
|
|
} |
418
|
|
|
|
419
|
|
|
return $request; |
420
|
|
|
} |
421
|
|
|
} |
422
|
|
|
|
This check looks for variable assignements that are either overwritten by other assignments or where the variable is not used subsequently.
Both the
$myVar
assignment in line 1 and the$higher
assignment in line 2 are dead. The first because$myVar
is never used and the second because$higher
is always overwritten for every possible time line.