Completed
Pull Request — master (#283)
by
unknown
05:02
created

ErrorHandler::ok()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 4
rs 10
c 0
b 0
f 0
cc 1
nc 1
nop 2
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'] ?? '';
0 ignored issues
show
Unused Code introduced by
$detail is not used, you could remove the assignment.

This check looks for variable assignements that are either overwritten by other assignments or where the variable is not used subsequently.

$myVar = 'Value';
$higher = false;

if (rand(1, 6) > 3) {
    $higher = true;
} else {
    $higher = false;
}

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.

Loading history...
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) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $json_response of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
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);
0 ignored issues
show
Security Debugging Code introduced by
var_dump('No place for s...wtf.', $json_response); looks like debug code. Are you sure you do not want to remove it? This might expose sensitive data.
Loading history...
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));
0 ignored issues
show
Documentation Bug introduced by
The method debug does not exist on object<DrewM\MailChimp\a...src/ErrorHandler.php$0>? Since you implemented __call, maybe consider adding a @method annotation.

If you implement __call and you know which methods are available, you can improve IDE auto-completion and static analysis by adding a @method annotation to the class.

This is often the case, when __call is implemented by a parent class and only the child class knows which methods exist:

class ParentClass {
    private $data = array();

    public function __call($method, array $args) {
        if (0 === strpos($method, 'get')) {
            return $this->data[strtolower(substr($method, 3))];
        }

        throw new \LogicException(sprintf('Unsupported method: %s', $method));
    }
}

/**
 * If this class knows which fields exist, you can specify the methods here:
 *
 * @method string getName()
 */
class SomeClass extends ParentClass { }
Loading history...
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));
0 ignored issues
show
Documentation Bug introduced by
The method error does not exist on object<DrewM\MailChimp\a...src/ErrorHandler.php$0>? Since you implemented __call, maybe consider adding a @method annotation.

If you implement __call and you know which methods are available, you can improve IDE auto-completion and static analysis by adding a @method annotation to the class.

This is often the case, when __call is implemented by a parent class and only the child class knows which methods exist:

class ParentClass {
    private $data = array();

    public function __call($method, array $args) {
        if (0 === strpos($method, 'get')) {
            return $this->data[strtolower(substr($method, 3))];
        }

        throw new \LogicException(sprintf('Unsupported method: %s', $method));
    }
}

/**
 * If this class knows which fields exist, you can specify the methods here:
 *
 * @method string getName()
 */
class SomeClass extends ParentClass { }
Loading history...
349
        } elseif (($r['status'] ?? null) == '400') {
350
            $logger->warning(self::interpolate($prefix . ': {substatus} other error: {response}', $data));
0 ignored issues
show
Documentation Bug introduced by
The method warning does not exist on object<DrewM\MailChimp\a...src/ErrorHandler.php$0>? Since you implemented __call, maybe consider adding a @method annotation.

If you implement __call and you know which methods are available, you can improve IDE auto-completion and static analysis by adding a @method annotation to the class.

This is often the case, when __call is implemented by a parent class and only the child class knows which methods exist:

class ParentClass {
    private $data = array();

    public function __call($method, array $args) {
        if (0 === strpos($method, 'get')) {
            return $this->data[strtolower(substr($method, 3))];
        }

        throw new \LogicException(sprintf('Unsupported method: %s', $method));
    }
}

/**
 * If this class knows which fields exist, you can specify the methods here:
 *
 * @method string getName()
 */
class SomeClass extends ParentClass { }
Loading history...
351
        }
352
        if ($code & self::ALREADY_EXISTS) {
353
            $data['detail'] = preg_replace('/Use PUT.*/', '', $data['detail']);
354
            $logger->debug(self::interpolate($prefix . ': {detail}', $data));
0 ignored issues
show
Documentation Bug introduced by
The method debug does not exist on object<DrewM\MailChimp\a...src/ErrorHandler.php$0>? Since you implemented __call, maybe consider adding a @method annotation.

If you implement __call and you know which methods are available, you can improve IDE auto-completion and static analysis by adding a @method annotation to the class.

This is often the case, when __call is implemented by a parent class and only the child class knows which methods exist:

class ParentClass {
    private $data = array();

    public function __call($method, array $args) {
        if (0 === strpos($method, 'get')) {
            return $this->data[strtolower(substr($method, 3))];
        }

        throw new \LogicException(sprintf('Unsupported method: %s', $method));
    }
}

/**
 * If this class knows which fields exist, you can specify the methods here:
 *
 * @method string getName()
 */
class SomeClass extends ParentClass { }
Loading history...
355
            return $code;
356
        } elseif ($code & self::INVALID_ADDRESS) {
357
            $logger->warning(self::interpolate($prefix . ': Invalid address: {body}', $data));
0 ignored issues
show
Documentation Bug introduced by
The method warning does not exist on object<DrewM\MailChimp\a...src/ErrorHandler.php$0>? Since you implemented __call, maybe consider adding a @method annotation.

If you implement __call and you know which methods are available, you can improve IDE auto-completion and static analysis by adding a @method annotation to the class.

This is often the case, when __call is implemented by a parent class and only the child class knows which methods exist:

class ParentClass {
    private $data = array();

    public function __call($method, array $args) {
        if (0 === strpos($method, 'get')) {
            return $this->data[strtolower(substr($method, 3))];
        }

        throw new \LogicException(sprintf('Unsupported method: %s', $method));
    }
}

/**
 * If this class knows which fields exist, you can specify the methods here:
 *
 * @method string getName()
 */
class SomeClass extends ParentClass { }
Loading history...
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));
0 ignored issues
show
Documentation Bug introduced by
The method debug does not exist on object<DrewM\MailChimp\a...src/ErrorHandler.php$0>? Since you implemented __call, maybe consider adding a @method annotation.

If you implement __call and you know which methods are available, you can improve IDE auto-completion and static analysis by adding a @method annotation to the class.

This is often the case, when __call is implemented by a parent class and only the child class knows which methods exist:

class ParentClass {
    private $data = array();

    public function __call($method, array $args) {
        if (0 === strpos($method, 'get')) {
            return $this->data[strtolower(substr($method, 3))];
        }

        throw new \LogicException(sprintf('Unsupported method: %s', $method));
    }
}

/**
 * If this class knows which fields exist, you can specify the methods here:
 *
 * @method string getName()
 */
class SomeClass extends ParentClass { }
Loading history...
365
            } else {
366
                $logger->warning(self::interpolate($prefix . ': status={status},title={title},detail={detail}', $data));
0 ignored issues
show
Documentation Bug introduced by
The method warning does not exist on object<DrewM\MailChimp\a...src/ErrorHandler.php$0>? Since you implemented __call, maybe consider adding a @method annotation.

If you implement __call and you know which methods are available, you can improve IDE auto-completion and static analysis by adding a @method annotation to the class.

This is often the case, when __call is implemented by a parent class and only the child class knows which methods exist:

class ParentClass {
    private $data = array();

    public function __call($method, array $args) {
        if (0 === strpos($method, 'get')) {
            return $this->data[strtolower(substr($method, 3))];
        }

        throw new \LogicException(sprintf('Unsupported method: %s', $method));
    }
}

/**
 * If this class knows which fields exist, you can specify the methods here:
 *
 * @method string getName()
 */
class SomeClass extends ParentClass { }
Loading history...
367
            }
368
            if (!$type) {
369
                $logger->debug(self::interpolate('{request} ++ {response} -- {json}', ['request' => $last_request,
0 ignored issues
show
Documentation Bug introduced by
The method debug does not exist on object<DrewM\MailChimp\a...src/ErrorHandler.php$0>? Since you implemented __call, maybe consider adding a @method annotation.

If you implement __call and you know which methods are available, you can improve IDE auto-completion and static analysis by adding a @method annotation to the class.

This is often the case, when __call is implemented by a parent class and only the child class knows which methods exist:

class ParentClass {
    private $data = array();

    public function __call($method, array $args) {
        if (0 === strpos($method, 'get')) {
            return $this->data[strtolower(substr($method, 3))];
        }

        throw new \LogicException(sprintf('Unsupported method: %s', $method));
    }
}

/**
 * If this class knows which fields exist, you can specify the methods here:
 *
 * @method string getName()
 */
class SomeClass extends ParentClass { }
Loading history...
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